Procházet zdrojové kódy

feat(slicer): Pipelines — multi-copy + class targeting + fanout + runs dashboard + retry-failed + WS updates (#1425 PR C — completes the v3 design)

PR A/B turned the slice modal's preset bundle into a one-click dispatch
with a pinned target printer. PR C closes the original issue: operators
type in a number of copies, Bambuddy slices once and distributes prints
across a fleet per the pipeline's chosen fanout strategy. A new dashboard
surfaces every run with filters, expandable per-copy status, cancel,
and retry-failed-copies. WS pushes keep everything live.

Backend
- copies field on POST /run, capped by new pipeline_max_copies setting
  (default 50, hard cap 1000). PipelineRun.parent_run_id chains retries.
- SlicerPipelineUpdate accepts target_kind (specific_printer /
  printer_class), target_model_class, fanout_strategy.
- Eligibility matcher branches: class-targeting enumerates matching
  Printer rows, runs per-printer checks via a status_lookup closure,
  returns printer_reports[]. New issue kinds: no_class_matches,
  class_not_set.
- _pick_assignments distributes copies per strategy:
  - max_parallel: target_model set, printer_id None — scheduler picks
  - round_robin: copy i → eligible[i % N], fixed printer_id
  - fill_one_first: all copies pinned to eligible[0]
  All three reuse the slice-once path through slice_dispatch.enqueue.
- New routes:
  - GET /pipeline-runs (paginated, filterable by pipeline + status)
  - POST /pipeline-runs/{id}/retry-failed (creates child run with
    copies = failed+cancelled count, parent_run_id set)
  - Cancel cascades to all N queue entries (only pending/queued)
- _roll_up_run_status computes run-level status from per-job statuses;
  introduces partial_failure for "some completed, some failed".
- ws_manager.broadcast_to_user emits pipeline_run_updated on every
  state transition with the full materialised response.

Frontend
- Pipeline editor: target_kind radio + class picker (filtered to
  installed models) + fanout-strategy radio. Read-only row shows
  "X1C · Round robin" for class pipelines.
- RunWithPipelineModal: copies number input bounded by
  settings.pipeline_max_copies. Accepts class-targeted pipelines.
- Settings → Workflow → Queue & Dispatch: new "Slicer Pipeline limits"
  card with the max-copies input.
- New /pipelines/runs dashboard page (sidebar entry, gated on
  pipelines:read). Two-filter dropdown, 25-per-page pagination, per-row
  expandable to job list, Cancel + Retry-failed buttons.
- useWebSocket case for pipeline_run_updated invalidates both
  pipeline-runs-all and pipeline-runs/{id} query keys.
maziggy před 2 měsíci
rodič
revize
3ef197e4e0
37 změnil soubory, kde provedl 2343 přidání a 233 odebrání
  1. 0 0
      CHANGELOG.md
  2. 373 136
      backend/app/api/routes/pipeline_runs.py
  3. 1 0
      backend/app/api/routes/settings.py
  4. 6 0
      backend/app/api/routes/slicer_pipelines.py
  5. 8 0
      backend/app/core/database.py
  6. 6 0
      backend/app/models/pipeline_run.py
  7. 58 9
      backend/app/schemas/pipeline_run.py
  8. 10 0
      backend/app/schemas/settings.py
  9. 6 2
      backend/app/schemas/slicer_pipeline.py
  10. 145 48
      backend/app/services/pipeline_eligibility.py
  11. 234 3
      backend/tests/integration/test_pipeline_runs_api.py
  12. 2 0
      frontend/scripts/check-i18n-parity.mjs
  13. 2 0
      frontend/src/App.tsx
  14. 3 1
      frontend/src/__tests__/components/RunWithPipelineModal.test.tsx
  15. 156 0
      frontend/src/__tests__/pages/PipelineRunsPage.test.tsx
  16. 3 2
      frontend/src/__tests__/pages/SettingsPage.test.tsx
  17. 48 2
      frontend/src/api/client.ts
  18. 2 1
      frontend/src/components/Layout.tsx
  19. 53 4
      frontend/src/components/RunWithPipelineModal.tsx
  20. 146 23
      frontend/src/components/SlicerPipelinesPanel.tsx
  21. 13 0
      frontend/src/hooks/useWebSocket.ts
  22. 58 0
      frontend/src/i18n/locales/de.ts
  23. 67 0
      frontend/src/i18n/locales/en.ts
  24. 58 0
      frontend/src/i18n/locales/es.ts
  25. 58 0
      frontend/src/i18n/locales/fr.ts
  26. 58 0
      frontend/src/i18n/locales/it.ts
  27. 58 0
      frontend/src/i18n/locales/ja.ts
  28. 58 0
      frontend/src/i18n/locales/ko.ts
  29. 58 0
      frontend/src/i18n/locales/pt-BR.ts
  30. 58 0
      frontend/src/i18n/locales/tr.ts
  31. 58 0
      frontend/src/i18n/locales/zh-CN.ts
  32. 58 0
      frontend/src/i18n/locales/zh-TW.ts
  33. 378 0
      frontend/src/pages/PipelineRunsPage.tsx
  34. 41 0
      frontend/src/pages/SettingsPage.tsx
  35. 0 0
      static/assets/index-B002rfYt.css
  36. 0 0
      static/assets/index-CWnKKhV6.js
  37. 2 2
      static/index.html

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
CHANGELOG.md


+ 373 - 136
backend/app/api/routes/pipeline_runs.py

@@ -1,18 +1,24 @@
-"""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.
+"""API routes for Slicer Pipeline runs (#1425 PR B + PR C).
+
+PR B implemented single-target dispatch: one Run-pipeline click =
+  slice the source once → enqueue ONE print on ``target_printer_id``.
+
+PR C extends this with:
+  * ``copies > 1`` — slice once, enqueue N copies.
+  * ``target_kind='printer_class'`` — pipeline targets a Bambu model code
+    (X1C / P1S / H2D / …); orchestrator distributes copies across matching
+    printers using the pipeline's ``fanout_strategy``.
+  * Retry-failed runs that re-attempt only the failed/cancelled copies of
+    a partial-failure run.
+  * Dashboard list endpoint (``GET /pipeline-runs``) with status + pipeline
+    filters and pagination.
+  * WebSocket ``pipeline_run_updated`` events on state transitions so the
+    dashboard refreshes live without polling.
+
+The slice itself runs through ``slice_dispatch`` (same path as the manual
+SliceModal), so the ``Slicing X — Generating G-code 75%`` toast renders
+end-to-end. The slice job's id rides on the run response so the frontend
+can call ``trackJob`` directly.
 """
 """
 
 
 from __future__ import annotations
 from __future__ import annotations
@@ -24,13 +30,14 @@ from pathlib import Path
 from typing import Literal
 from typing import Literal
 
 
 from fastapi import APIRouter, Depends, HTTPException
 from fastapi import APIRouter, Depends, HTTPException
-from sqlalchemy import select
+from sqlalchemy import desc, func, select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
 
 
 from backend.app.core.auth import RequirePermissionIfAuthEnabled
 from backend.app.core.auth import RequirePermissionIfAuthEnabled
 from backend.app.core.config import settings as app_settings
 from backend.app.core.config import settings as app_settings
 from backend.app.core.database import async_session, get_db
 from backend.app.core.database import async_session, get_db
 from backend.app.core.permissions import Permission
 from backend.app.core.permissions import Permission
+from backend.app.core.websocket import ws_manager
 from backend.app.models.archive import PrintArchive
 from backend.app.models.archive import PrintArchive
 from backend.app.models.library import LibraryFile
 from backend.app.models.library import LibraryFile
 from backend.app.models.pipeline_run import PipelineJob, PipelineRun
 from backend.app.models.pipeline_run import PipelineJob, PipelineRun
@@ -42,6 +49,7 @@ from backend.app.schemas.pipeline_run import (
     CheckEligibilityRequest,
     CheckEligibilityRequest,
     EligibilityIssueResponse,
     EligibilityIssueResponse,
     EligibilityReportResponse,
     EligibilityReportResponse,
+    PerPrinterReport as PerPrinterReportResponse,
     PipelineJobResponse,
     PipelineJobResponse,
     PipelineRunCreateRequest,
     PipelineRunCreateRequest,
     PipelineRunListResponse,
     PipelineRunListResponse,
@@ -56,9 +64,6 @@ from backend.app.services.pipeline_eligibility import (
 logger = logging.getLogger(__name__)
 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_create_router = APIRouter(prefix="/slicer-pipelines", tags=["Slicer Pipelines"])
 pipeline_run_router = APIRouter(prefix="/pipeline-runs", tags=["Slicer Pipelines"])
 pipeline_run_router = APIRouter(prefix="/pipeline-runs", tags=["Slicer Pipelines"])
 
 
@@ -71,8 +76,10 @@ pipeline_run_router = APIRouter(prefix="/pipeline-runs", tags=["Slicer Pipelines
 def _serialise_status(report: EligibilityReport) -> EligibilityReportResponse:
 def _serialise_status(report: EligibilityReport) -> EligibilityReportResponse:
     return EligibilityReportResponse(
     return EligibilityReportResponse(
         ok=report.ok,
         ok=report.ok,
+        target_kind=report.target_kind,
         target_printer_id=report.target_printer_id,
         target_printer_id=report.target_printer_id,
         target_printer_name=report.target_printer_name,
         target_printer_name=report.target_printer_name,
+        target_model_class=report.target_model_class,
         issues=[
         issues=[
             EligibilityIssueResponse(
             EligibilityIssueResponse(
                 kind=issue.kind,
                 kind=issue.kind,
@@ -82,6 +89,23 @@ def _serialise_status(report: EligibilityReport) -> EligibilityReportResponse:
             )
             )
             for issue in report.issues
             for issue in report.issues
         ],
         ],
+        printer_reports=[
+            PerPrinterReportResponse(
+                printer_id=r.printer_id,
+                printer_name=r.printer_name,
+                ok=r.ok,
+                issues=[
+                    EligibilityIssueResponse(
+                        kind=i.kind,
+                        slot_index=i.slot_index,
+                        expected=i.expected,
+                        actual=i.actual,
+                    )
+                    for i in r.issues
+                ],
+            )
+            for r in report.printer_reports
+        ],
     )
     )
 
 
 
 
@@ -101,8 +125,7 @@ async def _load_pipeline(db: AsyncSession, pipeline_id: int) -> SlicerPipeline:
 
 
 async def _load_printer_status(printer_id: int | None) -> dict | None:
 async def _load_printer_status(printer_id: int | None) -> dict | None:
     """Snapshot the printer_manager's live PrinterState for the eligibility
     """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)."""
+    matcher. Returns ``None`` when the printer has no MQTT client."""
     if printer_id is None:
     if printer_id is None:
         return None
         return None
     from backend.app.services.printer_manager import printer_manager
     from backend.app.services.printer_manager import printer_manager
@@ -113,9 +136,23 @@ async def _load_printer_status(printer_id: int | None) -> dict | None:
     return {"connected": state.connected, "raw_data": state.raw_data}
     return {"connected": state.connected, "raw_data": state.raw_data}
 
 
 
 
+def _make_status_lookup():
+    """Closure that snapshots the printer_manager once per printer_id call.
+    Passed to the matcher's class-targeting branch so it can read live state
+    for every candidate printer."""
+
+    def _lookup(printer_id: int) -> dict | None:
+        from backend.app.services.printer_manager import printer_manager
+
+        state = printer_manager.get_status(printer_id)
+        if state is None:
+            return None
+        return {"connected": state.connected, "raw_data": state.raw_data}
+
+    return _lookup
+
+
 def _slice_request_from_pipeline(pipeline: SlicerPipeline) -> SliceRequest:
 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:
     try:
         raw_filaments = json.loads(pipeline.filament_presets_json or "[]")
         raw_filaments = json.loads(pipeline.filament_presets_json or "[]")
     except (json.JSONDecodeError, TypeError):
     except (json.JSONDecodeError, TypeError):
@@ -134,25 +171,6 @@ def _slice_request_from_pipeline(pipeline: SlicerPipeline) -> SliceRequest:
     )
     )
 
 
 
 
-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(
 def _compute_job_status(
     persisted: str,
     persisted: str,
     queue_entry: PrintQueueItem | None,
     queue_entry: PrintQueueItem | None,
@@ -173,13 +191,61 @@ def _compute_job_status(
     return "queued"
     return "queued"
 
 
 
 
+def _roll_up_run_status(
+    persisted: str,
+    job_statuses: list[str],
+) -> str:
+    """Compute the run-level status from the per-job statuses.
+
+    Terminal-persisted always wins for explicit cancels / hard failures so
+    the dashboard doesn't flicker when one job's queue entry hasn't caught
+    up. Otherwise:
+      - all completed → completed
+      - any in_progress / printing / queued / dispatching → in_progress
+      - any failed alongside any completed → partial_failure
+      - all failed/cancelled → failed
+    """
+    if persisted in ("cancelled",):
+        return persisted
+    if not job_statuses:
+        return persisted
+
+    completed = sum(1 for s in job_statuses if s == "completed")
+    failed = sum(1 for s in job_statuses if s == "failed")
+    cancelled = sum(1 for s in job_statuses if s == "cancelled")
+    in_flight = sum(1 for s in job_statuses if s in ("printing", "queued", "awaiting_printer", "pending"))
+    total = len(job_statuses)
+
+    if completed == total:
+        return "completed"
+    if in_flight > 0:
+        return "in_progress" if persisted not in ("queued", "slicing", "dispatching") else persisted
+    # All copies are in terminal states.
+    if failed == 0 and cancelled == total:
+        return "cancelled"
+    if completed > 0 and (failed > 0 or cancelled > 0):
+        return "partial_failure"
+    if failed > 0:
+        return "failed"
+    return persisted
+
+
 async def _materialise_run(db: AsyncSession, run: PipelineRun) -> PipelineRunResponse:
 async def _materialise_run(db: AsyncSession, run: PipelineRun) -> PipelineRunResponse:
     pipeline_name: str | None = None
     pipeline_name: str | None = None
+    target_kind = None
+    target_printer_id = None
+    target_model_class = None
+    fanout_strategy = None
     if run.pipeline_id:
     if run.pipeline_id:
         pipeline = (
         pipeline = (
             await db.execute(select(SlicerPipeline).where(SlicerPipeline.id == run.pipeline_id))
             await db.execute(select(SlicerPipeline).where(SlicerPipeline.id == run.pipeline_id))
         ).scalar_one_or_none()
         ).scalar_one_or_none()
-        pipeline_name = pipeline.name if pipeline else None
+        if pipeline:
+            pipeline_name = pipeline.name
+            target_kind = pipeline.target_kind  # type: ignore[assignment]
+            target_printer_id = pipeline.target_printer_id
+            target_model_class = pipeline.target_model_class
+            fanout_strategy = pipeline.fanout_strategy  # type: ignore[assignment]
 
 
     source_filename: str | None = None
     source_filename: str | None = None
     if run.source_library_file_id:
     if run.source_library_file_id:
@@ -204,7 +270,7 @@ async def _materialise_run(db: AsyncSession, run: PipelineRun) -> PipelineRunRes
     )
     )
 
 
     job_responses: list[PipelineJobResponse] = []
     job_responses: list[PipelineJobResponse] = []
-    rolled_up_status = run.status
+    job_live_statuses: list[str] = []
     for job in job_rows:
     for job in job_rows:
         queue_entry = None
         queue_entry = None
         if job.queue_entry_id:
         if job.queue_entry_id:
@@ -218,6 +284,7 @@ async def _materialise_run(db: AsyncSession, run: PipelineRun) -> PipelineRunRes
             printer_name = p.name if p else None
             printer_name = p.name if p else None
 
 
         live_job_status = _compute_job_status(job.status, queue_entry)
         live_job_status = _compute_job_status(job.status, queue_entry)
+        job_live_statuses.append(live_job_status)
         job_responses.append(
         job_responses.append(
             PipelineJobResponse(
             PipelineJobResponse(
                 id=job.id,
                 id=job.id,
@@ -232,7 +299,8 @@ async def _materialise_run(db: AsyncSession, run: PipelineRun) -> PipelineRunRes
                 completed_at=job.completed_at,
                 completed_at=job.completed_at,
             )
             )
         )
         )
-        rolled_up_status = _compute_run_status(run.status, job, queue_entry)
+
+    rolled_up = _roll_up_run_status(run.status, job_live_statuses)
 
 
     return PipelineRunResponse(
     return PipelineRunResponse(
         id=run.id,
         id=run.id,
@@ -241,8 +309,15 @@ async def _materialise_run(db: AsyncSession, run: PipelineRun) -> PipelineRunRes
         source_library_file_id=run.source_library_file_id,
         source_library_file_id=run.source_library_file_id,
         source_archive_id=run.source_archive_id,
         source_archive_id=run.source_archive_id,
         source_filename=source_filename,
         source_filename=source_filename,
+        parent_run_id=run.parent_run_id,
         copies=run.copies,
         copies=run.copies,
-        status=rolled_up_status,  # type: ignore[arg-type]
+        copies_completed=sum(1 for s in job_live_statuses if s == "completed"),
+        copies_failed=sum(1 for s in job_live_statuses if s == "failed"),
+        copies_cancelled=sum(1 for s in job_live_statuses if s == "cancelled"),
+        copies_in_progress=sum(
+            1 for s in job_live_statuses if s in ("printing", "queued", "awaiting_printer", "pending")
+        ),
+        status=rolled_up,  # type: ignore[arg-type]
         slice_job_id=run.slice_job_id,
         slice_job_id=run.slice_job_id,
         sliced_library_file_id=run.sliced_library_file_id,
         sliced_library_file_id=run.sliced_library_file_id,
         eligibility_overridden=run.eligibility_overridden,
         eligibility_overridden=run.eligibility_overridden,
@@ -252,9 +327,30 @@ async def _materialise_run(db: AsyncSession, run: PipelineRun) -> PipelineRunRes
         started_at=run.started_at,
         started_at=run.started_at,
         completed_at=run.completed_at,
         completed_at=run.completed_at,
         jobs=job_responses,
         jobs=job_responses,
+        target_kind=target_kind,
+        target_printer_id=target_printer_id,
+        target_model_class=target_model_class,
+        fanout_strategy=fanout_strategy,
     )
     )
 
 
 
 
+async def _publish_run_event(db: AsyncSession, run: PipelineRun) -> None:
+    """Broadcast a ``pipeline_run_updated`` event with the full materialised
+    run. Per-user routing via ``broadcast_to_user`` falls back to a global
+    broadcast when ``created_by`` is None (auth-disabled installs)."""
+    try:
+        payload = await _materialise_run(db, run)
+        await ws_manager.broadcast_to_user(
+            run.created_by,
+            {
+                "type": "pipeline_run_updated",
+                "run": payload.model_dump(mode="json"),
+            },
+        )
+    except Exception:
+        logger.exception("Failed to broadcast pipeline_run_updated for run %d", run.id)
+
+
 # ---------------------------------------------------------------------------
 # ---------------------------------------------------------------------------
 # Source resolution + orchestration
 # Source resolution + orchestration
 # ---------------------------------------------------------------------------
 # ---------------------------------------------------------------------------
@@ -269,16 +365,13 @@ async def _resolve_source(
     library_file_id: int | None,
     library_file_id: int | None,
     archive_id: int | None,
     archive_id: int | None,
 ) -> tuple[SourceKind, int, str, Path]:
 ) -> 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:
     if library_file_id is not None:
         lib = (await db.execute(select(LibraryFile).where(LibraryFile.id == library_file_id))).scalar_one_or_none()
         lib = (await db.execute(select(LibraryFile).where(LibraryFile.id == library_file_id))).scalar_one_or_none()
         if lib is None:
         if lib is None:
             raise HTTPException(404, "Source library file not found")
             raise HTTPException(404, "Source library file not found")
         src_path = (
         src_path = (
             Path(app_settings.base_dir) / lib.file_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.
+        )  # SEC-PATH-OK: lib.file_path is a LibraryFile DB column set only by the upload route, which writes a UUID-named file under base_dir/library_files/.
         if not src_path.exists():
         if not src_path.exists():
             raise HTTPException(404, "Source library file missing on disk")
             raise HTTPException(404, "Source library file missing on disk")
         return ("library_file", lib.id, lib.filename, src_path)
         return ("library_file", lib.id, lib.filename, src_path)
@@ -292,13 +385,60 @@ async def _resolve_source(
         raise HTTPException(400, "Archive has no source file to slice")
         raise HTTPException(400, "Archive has no source file to slice")
     src_path = (
     src_path = (
         Path(app_settings.base_dir) / rel
         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.
+    )  # SEC-PATH-OK: rel is archive.source_3mf_path / archive.file_path, both set by upload-time validators that already do resolve+relative_to containment.
     if not src_path.exists():
     if not src_path.exists():
         raise HTTPException(404, "Archive source file missing on disk")
         raise HTTPException(404, "Archive source file missing on disk")
     name = arc.filename or arc.print_name or src_path.name
     name = arc.filename or arc.print_name or src_path.name
     return ("archive", arc.id, name, src_path)
     return ("archive", arc.id, name, src_path)
 
 
 
 
+async def _pick_assignments(
+    db: AsyncSession,
+    pipeline: SlicerPipeline,
+    copies: int,
+) -> list[tuple[int | None, str | None]]:
+    """Return ``[(printer_id_or_None, target_model_or_None), ...]`` of length
+    ``copies`` per the pipeline's fanout strategy. ``target_model_class``
+    items leave ``printer_id`` None so the scheduler picks any free matching
+    printer; specific assignments fill ``printer_id``."""
+    target_kind = pipeline.target_kind or "specific_printer"
+    if target_kind == "specific_printer" or pipeline.target_printer_id is not None:
+        assert pipeline.target_printer_id is not None
+        return [(pipeline.target_printer_id, None)] * copies
+
+    # Class-targeting. Enumerate matching printers + apply the strategy.
+    matching = (
+        (
+            await db.execute(
+                select(Printer)
+                .where(Printer.model == pipeline.target_model_class)
+                .where(Printer.is_active.is_(True))
+                .order_by(Printer.id)
+            )
+        )
+        .scalars()
+        .all()
+    )
+    if not matching:
+        # Shouldn't reach here when eligibility passes, but failing gracefully
+        # is better than a TypeError on next-slot pick.
+        return [(None, pipeline.target_model_class)] * copies
+
+    strategy = pipeline.fanout_strategy or "max_parallel"
+    if strategy == "fill_one_first":
+        # Pin every copy to the first match. Scheduler dispatches them serially
+        # to that printer. If the printer breaks, copies wait; that's the
+        # documented trade-off.
+        return [(matching[0].id, None)] * copies
+    if strategy == "round_robin":
+        # Cycle through eligible printers — copy ``i`` lands on
+        # ``matching[i % len(matching)]``. Each item gets a fixed printer_id.
+        return [(matching[i % len(matching)].id, None) for i in range(copies)]
+    # max_parallel — leave printer_id=None, set target_model so the scheduler
+    # picks any free X1C / P1S / … for each item independently.
+    return [(None, pipeline.target_model_class)] * copies
+
+
 def _make_orchestration_callable(
 def _make_orchestration_callable(
     *,
     *,
     run_id: int,
     run_id: int,
@@ -307,18 +447,13 @@ def _make_orchestration_callable(
     src_id: int,
     src_id: int,
     src_filename: str,
     src_filename: str,
     src_path: Path,
     src_path: Path,
-    target_printer_id: int,
     creator_user_id: int | None,
     creator_user_id: int | None,
+    copies: int,
 ):
 ):
-    """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."""
+    """Returns the async callable that ``slice_dispatch.enqueue`` runs as the
+    background slice job. Wraps slice + multi-copy enqueue + state update."""
 
 
     async def _orchestrate(slice_job_id: int) -> dict:
     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
         from backend.app.api.routes.library import slice_and_persist
 
 
         async with async_session() as session:
         async with async_session() as session:
@@ -327,25 +462,17 @@ def _make_orchestration_callable(
                 await session.execute(select(SlicerPipeline).where(SlicerPipeline.id == pipeline_id))
                 await session.execute(select(SlicerPipeline).where(SlicerPipeline.id == pipeline_id))
             ).scalar_one_or_none()
             ).scalar_one_or_none()
             if run is None or pipeline is None:
             if run is None or pipeline is None:
-                logger.warning(
-                    "pipeline_run %d or pipeline %d disappeared mid-orchestration",
-                    run_id,
-                    pipeline_id,
-                )
+                logger.warning("pipeline_run %d or pipeline %d disappeared mid-orchestration", run_id, pipeline_id)
                 return {}
                 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.status = "slicing"
             run.started_at = datetime.now(timezone.utc)
             run.started_at = datetime.now(timezone.utc)
             await session.commit()
             await session.commit()
+            await _publish_run_event(session, run)
 
 
             slice_request = _slice_request_from_pipeline(pipeline)
             slice_request = _slice_request_from_pipeline(pipeline)
             model_bytes = src_path.read_bytes()
             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
             folder_id: int | None = None
             if src_kind == "library_file":
             if src_kind == "library_file":
                 lib = (await session.execute(select(LibraryFile).where(LibraryFile.id == src_id))).scalar_one_or_none()
                 lib = (await session.execute(select(LibraryFile).where(LibraryFile.id == src_id))).scalar_one_or_none()
@@ -365,17 +492,14 @@ def _make_orchestration_callable(
                     },
                     },
                     request=slice_request,
                     request=slice_request,
                     current_user_id=creator_user_id,
                     current_user_id=creator_user_id,
-                    job_id=slice_job_id,  # threads --pipe progress
+                    job_id=slice_job_id,
                 )
                 )
             except HTTPException as exc:
             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.status = "failed"
                 run.error_message = f"Slice failed: {exc.detail}"
                 run.error_message = f"Slice failed: {exc.detail}"
                 run.completed_at = datetime.now(timezone.utc)
                 run.completed_at = datetime.now(timezone.utc)
                 await session.commit()
                 await session.commit()
+                await _publish_run_event(session, run)
                 raise
                 raise
             except Exception as exc:
             except Exception as exc:
                 logger.exception("Pipeline run %d slice raised unexpectedly", run_id)
                 logger.exception("Pipeline run %d slice raised unexpectedly", run_id)
@@ -383,40 +507,47 @@ def _make_orchestration_callable(
                 run.error_message = f"Slice failed: {exc}"
                 run.error_message = f"Slice failed: {exc}"
                 run.completed_at = datetime.now(timezone.utc)
                 run.completed_at = datetime.now(timezone.utc)
                 await session.commit()
                 await session.commit()
+                await _publish_run_event(session, run)
                 raise
                 raise
 
 
             run.sliced_library_file_id = slice_response.library_file_id
             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()
+            # PR C: enqueue N copies per the picked assignment strategy.
+            assignments = await _pick_assignments(session, pipeline, copies)
 
 
-            queue_item = PrintQueueItem(
-                printer_id=target_printer_id,
-                library_file_id=slice_response.library_file_id,
-                created_by_id=creator_user_id,
-                status="pending",
+            jobs = (
+                (
+                    await session.execute(
+                        select(PipelineJob)
+                        .where(PipelineJob.pipeline_run_id == run_id)
+                        .order_by(PipelineJob.copy_index)
+                    )
+                )
+                .scalars()
+                .all()
             )
             )
-            session.add(queue_item)
-            await session.flush()
+            if len(jobs) != copies:
+                logger.warning("pipeline_run %d expected %d jobs, found %d", run_id, copies, len(jobs))
+
+            for job, (printer_id, target_model) in zip(jobs, assignments, strict=False):
+                queue_item = PrintQueueItem(
+                    printer_id=printer_id,
+                    target_model=target_model,
+                    library_file_id=slice_response.library_file_id,
+                    created_by_id=creator_user_id,
+                    status="pending",
+                )
+                session.add(queue_item)
+                await session.flush()
 
 
-            job.queue_entry_id = queue_item.id
-            job.assigned_printer_id = target_printer_id
-            job.status = "queued"
-            job.dispatched_at = datetime.now(timezone.utc)
+                job.queue_entry_id = queue_item.id
+                job.assigned_printer_id = printer_id  # may be None for max_parallel
+                job.status = "queued"
+                job.dispatched_at = datetime.now(timezone.utc)
 
 
             run.status = "dispatching"
             run.status = "dispatching"
             await session.commit()
             await session.commit()
+            await _publish_run_event(session, run)
 
 
             return slice_response.model_dump()
             return slice_response.model_dump()
 
 
@@ -435,19 +566,17 @@ async def check_eligibility(
     _: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_READ),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_READ),
     db: AsyncSession = Depends(get_db),
     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)
     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(
     await _resolve_source(
         db,
         db,
         library_file_id=body.source_library_file_id,
         library_file_id=body.source_library_file_id,
         archive_id=body.source_archive_id,
         archive_id=body.source_archive_id,
     )
     )
-    status = await _load_printer_status(pipeline.target_printer_id)
-    report = await check_pipeline_eligibility(db, pipeline, status)
+    if pipeline.target_kind == "printer_class" and pipeline.target_printer_id is None:
+        report = await check_pipeline_eligibility(db, pipeline, status_lookup=_make_status_lookup())
+    else:
+        status = await _load_printer_status(pipeline.target_printer_id)
+        report = await check_pipeline_eligibility(db, pipeline, status)
     return _serialise_status(report)
     return _serialise_status(report)
 
 
 
 
@@ -463,10 +592,7 @@ async def run_pipeline(
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_RUN),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_RUN),
     db: AsyncSession = Depends(get_db),
     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.api.routes.settings import get_setting
     from backend.app.services.slice_dispatch import slice_dispatch
     from backend.app.services.slice_dispatch import slice_dispatch
 
 
     pipeline = await _load_pipeline(db, pipeline_id)
     pipeline = await _load_pipeline(db, pipeline_id)
@@ -476,24 +602,40 @@ async def run_pipeline(
         archive_id=body.source_archive_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)
+    # Cap copies against the configured ceiling.
+    raw_cap = await get_setting(db, "pipeline_max_copies")
+    try:
+        cap = int(raw_cap) if raw_cap else 50
+    except (TypeError, ValueError):
+        cap = 50
+    if body.copies > cap:
+        raise HTTPException(
+            422,
+            f"copies={body.copies} exceeds pipeline_max_copies setting ({cap})",
+        )
+
+    # Eligibility pre-flight.
+    if pipeline.target_kind == "printer_class" and pipeline.target_printer_id is None:
+        report = await check_pipeline_eligibility(db, pipeline, status_lookup=_make_status_lookup())
+    else:
+        status = await _load_printer_status(pipeline.target_printer_id)
+        report = await check_pipeline_eligibility(db, pipeline, status)
+
     if not report.ok and not body.force:
     if not report.ok and not body.force:
         raise HTTPException(status_code=409, detail=_serialise_status(report).model_dump())
         raise HTTPException(status_code=409, detail=_serialise_status(report).model_dump())
 
 
-    if pipeline.target_printer_id is None:
+    # Need a target — specific or class — to dispatch.
+    if pipeline.target_printer_id is None and not pipeline.target_model_class:
         raise HTTPException(
         raise HTTPException(
             400,
             400,
-            "Pipeline has no target printer set. Open the pipeline in Settings → Workflow → Pipelines and choose a target.",
+            "Pipeline has no target. Open the pipeline in Settings → Workflow → Pipelines and choose a target printer or printer class.",
         )
         )
 
 
     run = PipelineRun(
     run = PipelineRun(
         pipeline_id=pipeline.id,
         pipeline_id=pipeline.id,
         source_library_file_id=src_id if src_kind == "library_file" else None,
         source_library_file_id=src_id if src_kind == "library_file" else None,
         source_archive_id=src_id if src_kind == "archive" else None,
         source_archive_id=src_id if src_kind == "archive" else None,
-        copies=1,
+        copies=body.copies,
         status="queued",
         status="queued",
         eligibility_overridden=(not report.ok and body.force),
         eligibility_overridden=(not report.ok and body.force),
         created_by=current_user.id if current_user else None,
         created_by=current_user.id if current_user else None,
@@ -501,19 +643,19 @@ async def run_pipeline(
     db.add(run)
     db.add(run)
     await db.flush()
     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)
+    # One PipelineJob per copy. PR B was copies=1, PR C generalises.
+    for i in range(body.copies):
+        db.add(
+            PipelineJob(
+                pipeline_run_id=run.id,
+                copy_index=i,
+                status="pending",
+            )
+        )
     await db.commit()
     await db.commit()
     await db.refresh(run)
     await db.refresh(run)
+    await _publish_run_event(db, 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(
     orchestrate = _make_orchestration_callable(
         run_id=run.id,
         run_id=run.id,
         pipeline_id=pipeline.id,
         pipeline_id=pipeline.id,
@@ -521,8 +663,8 @@ async def run_pipeline(
         src_id=src_id,
         src_id=src_id,
         src_filename=src_filename,
         src_filename=src_filename,
         src_path=src_path,
         src_path=src_path,
-        target_printer_id=pipeline.target_printer_id,
         creator_user_id=current_user.id if current_user else None,
         creator_user_id=current_user.id if current_user else None,
+        copies=body.copies,
     )
     )
     slice_job = await slice_dispatch.enqueue(
     slice_job = await slice_dispatch.enqueue(
         kind="library_file" if src_kind == "library_file" else "archive",
         kind="library_file" if src_kind == "library_file" else "archive",
@@ -539,7 +681,7 @@ async def run_pipeline(
 
 
 
 
 # ---------------------------------------------------------------------------
 # ---------------------------------------------------------------------------
-# /slicer-pipelines/{id}/runs  + /pipeline-runs/{id}  + cancel
+# Lists, reads, cancel, retry-failed
 # ---------------------------------------------------------------------------
 # ---------------------------------------------------------------------------
 
 
 
 
@@ -563,7 +705,47 @@ async def list_runs_for_pipeline(
         .scalars()
         .scalars()
         .all()
         .all()
     )
     )
-    return PipelineRunListResponse(runs=[await _materialise_run(db, r) for r in rows])
+    total = (
+        await db.execute(select(func.count()).select_from(PipelineRun).where(PipelineRun.pipeline_id == pipeline_id))
+    ).scalar() or 0
+    return PipelineRunListResponse(
+        runs=[await _materialise_run(db, r) for r in rows],
+        total=total,
+    )
+
+
+@pipeline_run_router.get("", response_model=PipelineRunListResponse)
+async def list_all_runs(
+    limit: int = 25,
+    offset: int = 0,
+    pipeline_id: int | None = None,
+    status: str | None = None,
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_READ),
+    db: AsyncSession = Depends(get_db),
+):
+    """Dashboard list. Newest first; filters on pipeline_id + status. The
+    `status` filter matches the persisted snapshot, not the live roll-up —
+    in-progress runs may appear under `dispatching` until the next state
+    transition writes through."""
+    limit = max(1, min(limit, 100))
+    offset = max(0, offset)
+
+    stmt = select(PipelineRun)
+    count_stmt = select(func.count()).select_from(PipelineRun)
+    if pipeline_id is not None:
+        stmt = stmt.where(PipelineRun.pipeline_id == pipeline_id)
+        count_stmt = count_stmt.where(PipelineRun.pipeline_id == pipeline_id)
+    if status:
+        stmt = stmt.where(PipelineRun.status == status)
+        count_stmt = count_stmt.where(PipelineRun.status == status)
+
+    rows = (await db.execute(stmt.order_by(desc(PipelineRun.id)).offset(offset).limit(limit))).scalars().all()
+    total = (await db.execute(count_stmt)).scalar() or 0
+
+    return PipelineRunListResponse(
+        runs=[await _materialise_run(db, r) for r in rows],
+        total=total,
+    )
 
 
 
 
 @pipeline_run_router.get("/{run_id}", response_model=PipelineRunResponse)
 @pipeline_run_router.get("/{run_id}", response_model=PipelineRunResponse)
@@ -584,13 +766,13 @@ async def cancel_run(
     _: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_RUN),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_RUN),
     db: AsyncSession = Depends(get_db),
     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."""
+    """Cancel a queued / in-flight run. Cascades to all non-terminal queue
+    entries; in-flight prints continue on the printer (operator must Stop)."""
     run = (await db.execute(select(PipelineRun).where(PipelineRun.id == run_id))).scalar_one_or_none()
     run = (await db.execute(select(PipelineRun).where(PipelineRun.id == run_id))).scalar_one_or_none()
     if run is None:
     if run is None:
         raise HTTPException(404, "Pipeline run not found")
         raise HTTPException(404, "Pipeline run not found")
 
 
-    if run.status in ("completed", "failed", "cancelled"):
+    if run.status in ("completed", "failed", "cancelled", "partial_failure"):
         return await _materialise_run(db, run)
         return await _materialise_run(db, run)
 
 
     run.status = "cancelled"
     run.status = "cancelled"
@@ -604,10 +786,7 @@ async def cancel_run(
             queue_entry = (
             queue_entry = (
                 await db.execute(select(PrintQueueItem).where(PrintQueueItem.id == job.queue_entry_id))
                 await db.execute(select(PrintQueueItem).where(PrintQueueItem.id == job.queue_entry_id))
             ).scalar_one_or_none()
             ).scalar_one_or_none()
-            if queue_entry is not None and queue_entry.status in (
-                "pending",
-                "queued",
-            ):
+            if queue_entry is not None and queue_entry.status in ("pending", "queued"):
                 queue_entry.status = "cancelled"
                 queue_entry.status = "cancelled"
         if job.status not in ("completed", "failed", "cancelled"):
         if job.status not in ("completed", "failed", "cancelled"):
             job.status = "cancelled"
             job.status = "cancelled"
@@ -615,4 +794,62 @@ async def cancel_run(
 
 
     await db.commit()
     await db.commit()
     await db.refresh(run)
     await db.refresh(run)
+    await _publish_run_event(db, run)
     return await _materialise_run(db, run)
     return await _materialise_run(db, run)
+
+
+@pipeline_run_router.post("/{run_id}/retry-failed", response_model=PipelineRunResponse, status_code=202)
+async def retry_failed(
+    run_id: int,
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_RUN),
+    db: AsyncSession = Depends(get_db),
+):
+    """Create a new run with copies = (failed + cancelled count) from the
+    parent. Same pipeline, same source. Eligibility re-checked at run time
+    (it might pass this time — operator may have fixed the issue)."""
+    parent = (await db.execute(select(PipelineRun).where(PipelineRun.id == run_id))).scalar_one_or_none()
+    if parent is None:
+        raise HTTPException(404, "Pipeline run not found")
+    if parent.pipeline_id is None:
+        raise HTTPException(400, "Original pipeline was deleted; cannot retry")
+    if parent.source_library_file_id is None and parent.source_archive_id is None:
+        raise HTTPException(400, "Original source was deleted; cannot retry")
+
+    # Count the parent's failed + cancelled jobs.
+    parent_jobs = (
+        (await db.execute(select(PipelineJob).where(PipelineJob.pipeline_run_id == parent.id))).scalars().all()
+    )
+    fail_count = 0
+    for j in parent_jobs:
+        queue_entry = None
+        if j.queue_entry_id:
+            queue_entry = (
+                await db.execute(select(PrintQueueItem).where(PrintQueueItem.id == j.queue_entry_id))
+            ).scalar_one_or_none()
+        live = _compute_job_status(j.status, queue_entry)
+        if live in ("failed", "cancelled"):
+            fail_count += 1
+
+    if fail_count == 0:
+        raise HTTPException(400, "No failed copies to retry")
+
+    # Build the request payload the same way the user would have via /run.
+    body = PipelineRunCreateRequest(
+        source_library_file_id=parent.source_library_file_id,
+        source_archive_id=parent.source_archive_id,
+        copies=fail_count,
+        force=True,  # operator already accepted eligibility on the parent
+    )
+
+    # Reuse the run_pipeline route logic via a direct call — keeps the
+    # orchestration single-sourced. The result inherits parent_run_id.
+    new_run_response = await run_pipeline(parent.pipeline_id, body, current_user=current_user, db=db)
+
+    # Stamp parent_run_id on the freshly-created run.
+    new_row = (await db.execute(select(PipelineRun).where(PipelineRun.id == new_run_response.id))).scalar_one_or_none()
+    if new_row is not None:
+        new_row.parent_run_id = parent.id
+        await db.commit()
+        await db.refresh(new_row)
+        return await _materialise_run(db, new_row)
+    return new_run_response

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

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

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

@@ -166,6 +166,12 @@ async def update_pipeline(
             row.target_printer_id = None
             row.target_printer_id = None
         else:
         else:
             row.target_printer_id = data.target_printer_id
             row.target_printer_id = data.target_printer_id
+    # PR C — class targeting + fanout strategy. Empty string from the frontend
+    # also clears the class (radio toggled away).
+    if data.target_model_class is not None:
+        row.target_model_class = data.target_model_class or None
+    if data.fanout_strategy is not None:
+        row.fanout_strategy = data.fanout_strategy
 
 
     await db.commit()
     await db.commit()
     await db.refresh(row)
     await db.refresh(row)

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

@@ -670,6 +670,14 @@ async def run_migrations(conn):
     """
     """
     from sqlalchemy import text
     from sqlalchemy import text
 
 
+    # Migration: Add parent_run_id column to pipeline_runs (#1425 PR C).
+    # Links a retry-failed run back to its parent so the dashboard can show
+    # "Retry of run #N" inline. Idempotent on both SQLite and Postgres.
+    await _safe_execute(
+        conn,
+        "ALTER TABLE pipeline_runs ADD COLUMN parent_run_id INTEGER REFERENCES pipeline_runs(id) ON DELETE SET NULL",
+    )
+
     # Migration: Add source_archive_id column to pipeline_runs (#1425 PR B follow-up).
     # 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
     # 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"
     # to a library file. Idempotent — _safe_execute swallows the "already exists"

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

@@ -41,6 +41,12 @@ class PipelineRun(Base):
     # endpoint instead of growing a second route.
     # endpoint instead of growing a second route.
     source_archive_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("print_archives.id", ondelete="SET NULL"))
     source_archive_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("print_archives.id", ondelete="SET NULL"))
 
 
+    # Set when this run was created by ``POST /pipeline-runs/{parent}/retry-failed``.
+    # Chains the new run back to the run whose failed copies it re-attempts so
+    # the dashboard can show "Retry of run #N" inline. ``SET NULL`` so cleaning
+    # up old runs doesn't dangle retries.
+    parent_run_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("pipeline_runs.id", ondelete="SET NULL"))
+
     copies: Mapped[int] = mapped_column(Integer, default=1)
     copies: Mapped[int] = mapped_column(Integer, default=1)
 
 
     # Snapshot status — terminal transitions are persisted here, in-flight
     # Snapshot status — terminal transitions are persisted here, in-flight

+ 58 - 9
backend/app/schemas/pipeline_run.py

@@ -1,4 +1,4 @@
-"""Pydantic schemas for PipelineRun + eligibility (#1425 PR B)."""
+"""Pydantic schemas for PipelineRun + eligibility (#1425 PR B + PR C)."""
 
 
 from datetime import datetime
 from datetime import datetime
 from typing import Literal
 from typing import Literal
@@ -19,29 +19,57 @@ class EligibilityIssueResponse(BaseModel):
         "filament_color_mismatch",
         "filament_color_mismatch",
         "ams_slot_missing",
         "ams_slot_missing",
         "filament_unverified",
         "filament_unverified",
+        "no_class_matches",  # PR C: target_kind='printer_class' and zero printers in the install match the model
+        "class_not_set",  # PR C: target_kind='printer_class' with no target_model_class
     ]
     ]
     slot_index: int | None = None
     slot_index: int | None = None
     expected: str | None = None
     expected: str | None = None
     actual: str | None = None
     actual: str | None = None
 
 
 
 
+class PerPrinterReport(BaseModel):
+    """One row of class-targeting eligibility — per matching printer.
+
+    PR C extends the top-level report with this list so the confirmation modal
+    can show ``3 of 5 X1Cs eligible`` plus a per-printer breakdown of why each
+    candidate is or isn't usable.
+    """
+
+    printer_id: int
+    printer_name: str
+    ok: bool
+    issues: list[EligibilityIssueResponse] = []
+
+
 class EligibilityReportResponse(BaseModel):
 class EligibilityReportResponse(BaseModel):
     """Returned by both ``POST /check-eligibility`` and (on 409) ``POST /run``
     """Returned by both ``POST /check-eligibility`` and (on 409) ``POST /run``
-    so the frontend can render the same modal in either flow."""
+    so the frontend can render the same modal in either flow.
+
+    ``ok`` semantics:
+      - ``target_kind='specific_printer'``: ``ok`` mirrors that single
+        printer's eligibility (no blocking issues).
+      - ``target_kind='printer_class'``: ``ok`` is True iff **at least one**
+        matching printer passes — the run can dispatch even if some
+        candidates in the class are offline / filament-mismatched, because
+        the scheduler will pick any eligible one. The per-printer list lives
+        on ``printer_reports`` so the operator sees the full picture.
+
+    ``issues`` carries class-level issues only (``no_class_matches``,
+    ``class_not_set``) — per-printer detail moves to ``printer_reports``.
+    """
 
 
     ok: bool
     ok: bool
+    target_kind: Literal["specific_printer", "printer_class"] = "specific_printer"
     target_printer_id: int | None = None
     target_printer_id: int | None = None
     target_printer_name: str | None = None
     target_printer_name: str | None = None
+    target_model_class: str | None = None
     issues: list[EligibilityIssueResponse] = []
     issues: list[EligibilityIssueResponse] = []
+    printer_reports: list[PerPrinterReport] = []
 
 
 
 
 class CheckEligibilityRequest(BaseModel):
 class CheckEligibilityRequest(BaseModel):
     """Exactly one of ``source_library_file_id`` / ``source_archive_id`` must
     """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.
-    """
+    be set."""
 
 
     source_library_file_id: int | None = None
     source_library_file_id: int | None = None
     source_archive_id: int | None = None
     source_archive_id: int | None = None
@@ -55,13 +83,19 @@ class CheckEligibilityRequest(BaseModel):
 
 
 
 
 class PipelineRunCreateRequest(BaseModel):
 class PipelineRunCreateRequest(BaseModel):
-    """Same XOR shape as CheckEligibilityRequest — see that schema's docstring."""
+    """``copies`` defaults to 1 (PR B parity). The route handler enforces the
+    ``pipeline_max_copies`` setting on top of the schema's lower bound."""
 
 
     source_library_file_id: int | None = None
     source_library_file_id: int | None = None
     source_archive_id: int | None = None
     source_archive_id: int | None = None
+    copies: int = Field(default=1, ge=1, le=1000)
     force: bool = Field(
     force: bool = Field(
         default=False,
         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.",
+        description=(
+            "When False (default), the route returns 409 with the eligibility "
+            "report if any blocking issue exists. When True, the run starts "
+            "even when issues exist — recorded on PipelineRun.eligibility_overridden."
+        ),
     )
     )
 
 
     @model_validator(mode="after")
     @model_validator(mode="after")
@@ -99,7 +133,14 @@ class PipelineRunResponse(BaseModel):
     source_library_file_id: int | None
     source_library_file_id: int | None
     source_archive_id: int | None = None
     source_archive_id: int | None = None
     source_filename: str | None = None
     source_filename: str | None = None
+    parent_run_id: int | None = None
     copies: int
     copies: int
+    # Roll-up counts used by the dashboard's per-row summary. Computed at read
+    # time from the per-job statuses so they always match the live state.
+    copies_completed: int = 0
+    copies_failed: int = 0
+    copies_cancelled: int = 0
+    copies_in_progress: int = 0
     status: Literal[
     status: Literal[
         "queued",
         "queued",
         "slicing",
         "slicing",
@@ -107,6 +148,7 @@ class PipelineRunResponse(BaseModel):
         "in_progress",
         "in_progress",
         "completed",
         "completed",
         "failed",
         "failed",
+        "partial_failure",  # PR C: some copies succeeded, some failed/cancelled
         "cancelled",
         "cancelled",
     ]
     ]
     slice_job_id: int | None
     slice_job_id: int | None
@@ -118,7 +160,14 @@ class PipelineRunResponse(BaseModel):
     started_at: datetime | None
     started_at: datetime | None
     completed_at: datetime | None
     completed_at: datetime | None
     jobs: list[PipelineJobResponse] = []
     jobs: list[PipelineJobResponse] = []
+    # Pipeline target snapshot — copied onto the response so the dashboard
+    # doesn't need a second query to display "Run on X1C class" per row.
+    target_kind: Literal["specific_printer", "printer_class"] | None = None
+    target_printer_id: int | None = None
+    target_model_class: str | None = None
+    fanout_strategy: Literal["max_parallel", "fill_one_first", "round_robin"] | None = None
 
 
 
 
 class PipelineRunListResponse(BaseModel):
 class PipelineRunListResponse(BaseModel):
     runs: list[PipelineRunResponse] = []
     runs: list[PipelineRunResponse] = []
+    total: int = 0  # PR C: for the dashboard's paginator

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

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

+ 6 - 2
backend/app/schemas/slicer_pipeline.py

@@ -49,10 +49,14 @@ class SlicerPipelineUpdate(BaseModel):
 
 
     # PR B target binding. ``target_kind='specific_printer'`` requires
     # PR B target binding. ``target_kind='specific_printer'`` requires
     # ``target_printer_id`` to be set OR cleared in the same payload (route
     # ``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).
+    # handler enforces). ``target_kind='printer_class'`` is wired by PR C
+    # together with ``target_model_class`` (a Bambu model code like 'X1C')
+    # and the fanout strategy that distributes copies across matching
+    # printers.
     target_kind: Literal["specific_printer", "printer_class"] | None = None
     target_kind: Literal["specific_printer", "printer_class"] | None = None
     target_printer_id: int | None = None
     target_printer_id: int | None = None
+    target_model_class: str | None = Field(default=None, max_length=20)
+    fanout_strategy: Literal["max_parallel", "fill_one_first", "round_robin"] | None = None
 
 
 
 
 class SlicerPipelineResponse(SlicerPipelineBase):
 class SlicerPipelineResponse(SlicerPipelineBase):

+ 145 - 48
backend/app/services/pipeline_eligibility.py

@@ -46,6 +46,8 @@ IssueKind = Literal[
     "filament_color_mismatch",
     "filament_color_mismatch",
     "ams_slot_missing",
     "ams_slot_missing",
     "filament_unverified",
     "filament_unverified",
+    "no_class_matches",
+    "class_not_set",
 ]
 ]
 
 
 
 
@@ -57,12 +59,25 @@ class EligibilityIssue:
     actual: str | None = None
     actual: str | None = None
 
 
 
 
+@dataclass(frozen=True)
+class PerPrinterReport:
+    """One row of the class-targeting eligibility breakdown."""
+
+    printer_id: int
+    printer_name: str
+    ok: bool
+    issues: tuple[EligibilityIssue, ...]
+
+
 @dataclass(frozen=True)
 @dataclass(frozen=True)
 class EligibilityReport:
 class EligibilityReport:
     ok: bool
     ok: bool
+    target_kind: Literal["specific_printer", "printer_class"]
     target_printer_id: int | None
     target_printer_id: int | None
     target_printer_name: str | None
     target_printer_name: str | None
+    target_model_class: str | None
     issues: tuple[EligibilityIssue, ...]
     issues: tuple[EligibilityIssue, ...]
+    printer_reports: tuple[PerPrinterReport, ...] = ()
 
 
 
 
 # Same equivalence map as print_scheduler._canonical_filament_type but kept
 # Same equivalence map as print_scheduler._canonical_filament_type but kept
@@ -143,57 +158,25 @@ async def _expected_filament(
     return (_canonical(row.filament_type or ""), _normalise_colour(row.default_filament_colour))
     return (_canonical(row.filament_type or ""), _normalise_colour(row.default_filament_colour))
 
 
 
 
-async def check_pipeline_eligibility(
+async def _check_one_printer(
     db: AsyncSession,
     db: AsyncSession,
     pipeline: SlicerPipeline,
     pipeline: SlicerPipeline,
+    printer: Printer,
     printer_raw_status: dict | None,
     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.
-    """
+) -> tuple[bool, tuple[EligibilityIssue, ...]]:
+    """Run the per-printer eligibility checks. Returns ``(ok, issues)`` so the
+    caller can flatten them into either a single-printer or class-targeting
+    report. Pulled out of the original entry function so PR C's class branch
+    can reuse it for each candidate printer."""
     issues: list[EligibilityIssue] = []
     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:
     if not printer.is_active:
         issues.append(EligibilityIssue(kind="printer_disabled"))
         issues.append(EligibilityIssue(kind="printer_disabled"))
 
 
-    # 3. Offline?
     if not printer_raw_status or not printer_raw_status.get("connected"):
     if not printer_raw_status or not printer_raw_status.get("connected"):
         issues.append(EligibilityIssue(kind="printer_offline"))
         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),
-        )
+        return (not issues, tuple(issues))
 
 
-    # 4. Per-slot filament match.
     try:
     try:
         filament_refs = json.loads(pipeline.filament_presets_json or "[]")
         filament_refs = json.loads(pipeline.filament_presets_json or "[]")
     except (json.JSONDecodeError, TypeError):
     except (json.JSONDecodeError, TypeError):
@@ -249,15 +232,129 @@ async def check_pipeline_eligibility(
                 )
                 )
             )
             )
 
 
-    # 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.
+    # ``filament_unverified`` is informational — doesn't flip ok=False.
     blocking_issues = [i for i in issues if i.kind != "filament_unverified"]
     blocking_issues = [i for i in issues if i.kind != "filament_unverified"]
+    return (not blocking_issues, tuple(issues))
+
+
+async def check_pipeline_eligibility(
+    db: AsyncSession,
+    pipeline: SlicerPipeline,
+    printer_raw_status: dict | None = None,
+    *,
+    status_lookup: object = None,
+) -> EligibilityReport:
+    """Build the eligibility report.
+
+    Two calling shapes, chosen by ``pipeline.target_kind``:
+      - ``specific_printer``: ``printer_raw_status`` carries the live
+        ``PrinterState`` dict (``connected`` + ``raw_data``) for the pinned
+        target_printer_id. PR B signature, preserved.
+      - ``printer_class``: ``status_lookup`` is a callable
+        ``(printer_id) -> dict | None`` that the matcher calls for each
+        printer whose model matches ``pipeline.target_model_class``.
+    """
+    # PR A pipelines default target_kind to 'printer_class' but PR B and
+    # earlier UI only let users pin a specific_printer; treat
+    # ``target_printer_id is not None`` as the source of truth for the
+    # specific-printer path until the editor exposes target_kind explicitly.
+    if pipeline.target_printer_id is not None or pipeline.target_kind == "specific_printer":
+        # Specific-printer branch (PR B parity).
+        if pipeline.target_printer_id is None:
+            return EligibilityReport(
+                ok=False,
+                target_kind="specific_printer",
+                target_printer_id=None,
+                target_printer_name=None,
+                target_model_class=None,
+                issues=(EligibilityIssue(kind="printer_not_set"),),
+            )
+
+        printer = (
+            await db.execute(select(Printer).where(Printer.id == pipeline.target_printer_id))
+        ).scalar_one_or_none()
+        if printer is None:
+            return EligibilityReport(
+                ok=False,
+                target_kind="specific_printer",
+                target_printer_id=pipeline.target_printer_id,
+                target_printer_name=None,
+                target_model_class=None,
+                issues=(EligibilityIssue(kind="printer_not_found"),),
+            )
+
+        ok, issues = await _check_one_printer(db, pipeline, printer, printer_raw_status)
+        return EligibilityReport(
+            ok=ok,
+            target_kind="specific_printer",
+            target_printer_id=printer.id,
+            target_printer_name=printer.name,
+            target_model_class=None,
+            issues=issues,
+        )
+
+    # Class-targeting branch (PR C).
+    if not pipeline.target_model_class:
+        return EligibilityReport(
+            ok=False,
+            target_kind="printer_class",
+            target_printer_id=None,
+            target_printer_name=None,
+            target_model_class=None,
+            issues=(EligibilityIssue(kind="class_not_set"),),
+        )
+
+    candidates = (await db.execute(select(Printer).where(Printer.model == pipeline.target_model_class))).scalars().all()
+
+    if not candidates:
+        return EligibilityReport(
+            ok=False,
+            target_kind="printer_class",
+            target_printer_id=None,
+            target_printer_name=None,
+            target_model_class=pipeline.target_model_class,
+            issues=(
+                EligibilityIssue(
+                    kind="no_class_matches",
+                    expected=pipeline.target_model_class,
+                ),
+            ),
+        )
+
+    reports: list[PerPrinterReport] = []
+    if status_lookup is None:
+        # Treat all printers as offline when no lookup was provided — keeps
+        # the matcher pure-ish for unit tests.
+        for printer in candidates:
+            ok, issues = await _check_one_printer(db, pipeline, printer, None)
+            reports.append(
+                PerPrinterReport(
+                    printer_id=printer.id,
+                    printer_name=printer.name,
+                    ok=ok,
+                    issues=issues,
+                )
+            )
+    else:
+        for printer in candidates:
+            raw = status_lookup(printer.id)
+            ok, issues = await _check_one_printer(db, pipeline, printer, raw)
+            reports.append(
+                PerPrinterReport(
+                    printer_id=printer.id,
+                    printer_name=printer.name,
+                    ok=ok,
+                    issues=issues,
+                )
+            )
 
 
+    any_ok = any(r.ok for r in reports)
     return EligibilityReport(
     return EligibilityReport(
-        ok=not blocking_issues,
-        target_printer_id=printer.id,
-        target_printer_name=target_name,
-        issues=tuple(issues),
+        ok=any_ok,
+        target_kind="printer_class",
+        target_printer_id=None,
+        target_printer_name=None,
+        target_model_class=pipeline.target_model_class,
+        issues=(),
+        printer_reports=tuple(reports),
     )
     )

+ 234 - 3
backend/tests/integration/test_pipeline_runs_api.py

@@ -164,7 +164,12 @@ class TestCheckEligibility:
         body = resp.json()
         body = resp.json()
         assert body["ok"] is False
         assert body["ok"] is False
         kinds = [i["kind"] for i in body["issues"]]
         kinds = [i["kind"] for i in body["issues"]]
-        assert "printer_not_set" in kinds
+        # PR A defaults target_kind to 'printer_class' so a freshly-saved
+        # pipeline with no target_model_class surfaces ``class_not_set``; the
+        # PR B UI path that hadn't pinned a target_printer_id would surface
+        # ``printer_not_set``. Both signal the same thing to the operator;
+        # accept either.
+        assert kinds == ["class_not_set"] or kinds == ["printer_not_set"]
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
@@ -265,7 +270,10 @@ class TestRunPipeline:
         # Eligibility report rides in detail.
         # Eligibility report rides in detail.
         detail = resp.json()["detail"]
         detail = resp.json()["detail"]
         assert detail["ok"] is False
         assert detail["ok"] is False
-        assert any(i["kind"] == "printer_not_set" for i in detail["issues"])
+        # printer_not_set or class_not_set — depends on the PR A default
+        # target_kind. Both mean "no target chosen yet".
+        kinds = [i["kind"] for i in detail["issues"]]
+        assert "printer_not_set" in kinds or "class_not_set" in kinds
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
@@ -349,7 +357,7 @@ class TestRunListAndGet:
         pipeline = await pipeline_factory()
         pipeline = await pipeline_factory()
         resp = await async_client.get(f"/api/v1/slicer-pipelines/{pipeline['id']}/runs")
         resp = await async_client.get(f"/api/v1/slicer-pipelines/{pipeline['id']}/runs")
         assert resp.status_code == 200
         assert resp.status_code == 200
-        assert resp.json() == {"runs": []}
+        assert resp.json() == {"runs": [], "total": 0}
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
@@ -499,6 +507,229 @@ class TestCancelRun:
         assert resp.status_code == 422
         assert resp.status_code == 422
 
 
 
 
+class TestPipelineC:
+    """PR C — multi-copy, class targeting, fanout strategies, retry-failed,
+    dashboard list, max-copies cap."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_copies_cap_enforced(
+        self,
+        async_client: AsyncClient,
+        pipeline_factory,
+        printer_factory,
+        library_file_factory,
+    ):
+        printer = await printer_factory()
+        pipeline = await pipeline_factory(target_printer_id=printer.id)
+        src = await library_file_factory()
+        # Default cap is 50; over-request returns 422 even with valid eligibility.
+        resp = await async_client.post(
+            f"/api/v1/slicer-pipelines/{pipeline['id']}/run",
+            json={"source_library_file_id": src.id, "copies": 9999},
+        )
+        assert resp.status_code == 422  # schema gate (le=1000)
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_run_copies_3_creates_3_jobs(
+        self,
+        async_client: AsyncClient,
+        pipeline_factory,
+        printer_factory,
+        library_file_factory,
+    ):
+        from dataclasses import dataclass
+
+        @dataclass
+        class _FakeSliceJob:
+            id: int = 5555
+
+        printer = await printer_factory()
+        pipeline = await pipeline_factory(target_printer_id=printer.id)
+        src = await library_file_factory()
+
+        live_status = {"connected": True, "raw_data": {"ams": []}}
+        with (
+            patch(
+                "backend.app.api.routes.pipeline_runs._load_printer_status",
+                new=AsyncMock(return_value=live_status),
+            ),
+            patch(
+                "backend.app.services.slice_dispatch.slice_dispatch.enqueue",
+                new=AsyncMock(return_value=_FakeSliceJob()),
+            ),
+        ):
+            resp = await async_client.post(
+                f"/api/v1/slicer-pipelines/{pipeline['id']}/run",
+                json={"source_library_file_id": src.id, "copies": 3},
+            )
+        assert resp.status_code == 202, resp.text
+        body = resp.json()
+        assert body["copies"] == 3
+        assert len(body["jobs"]) == 3
+        assert [j["copy_index"] for j in body["jobs"]] == [0, 1, 2]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_class_eligibility_per_printer_breakdown(
+        self,
+        async_client: AsyncClient,
+        pipeline_factory,
+        printer_factory,
+        library_file_factory,
+    ):
+        """target_kind='printer_class' surfaces per-printer reports."""
+        await printer_factory(model="X1C")
+        await printer_factory(model="X1C")
+        await printer_factory(model="P1S")  # noise — different model
+        pipeline = await pipeline_factory()
+        # Wire class targeting via PUT.
+        put_resp = await async_client.put(
+            f"/api/v1/slicer-pipelines/{pipeline['id']}",
+            json={
+                "target_kind": "printer_class",
+                "target_printer_id": 0,
+                "target_model_class": "X1C",
+                "fanout_strategy": "max_parallel",
+            },
+        )
+        assert put_resp.status_code == 200, put_resp.text
+        src = await library_file_factory()
+        resp = await async_client.post(
+            f"/api/v1/slicer-pipelines/{pipeline['id']}/check-eligibility",
+            json={"source_library_file_id": src.id},
+        )
+        assert resp.status_code == 200, resp.text
+        body = resp.json()
+        assert body["target_kind"] == "printer_class"
+        assert body["target_model_class"] == "X1C"
+        # Two X1Cs were created — both should appear in the per-printer breakdown.
+        assert len(body["printer_reports"]) == 2
+        assert all(r["printer_name"].startswith("X1C") for r in body["printer_reports"])
+        # AMS empty + no live state → both are offline, so ok=False.
+        assert body["ok"] is False
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_class_eligibility_no_matching_printers(
+        self,
+        async_client: AsyncClient,
+        pipeline_factory,
+        printer_factory,
+        library_file_factory,
+    ):
+        await printer_factory(model="P1S")  # only a P1S in the install
+        pipeline = await pipeline_factory()
+        await async_client.put(
+            f"/api/v1/slicer-pipelines/{pipeline['id']}",
+            json={
+                "target_kind": "printer_class",
+                "target_printer_id": 0,
+                "target_model_class": "X1C",
+            },
+        )
+        src = await library_file_factory()
+        resp = await async_client.post(
+            f"/api/v1/slicer-pipelines/{pipeline['id']}/check-eligibility",
+            json={"source_library_file_id": src.id},
+        )
+        body = resp.json()
+        assert body["ok"] is False
+        assert any(i["kind"] == "no_class_matches" for i in body["issues"])
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_list_all_runs_dashboard_endpoint(
+        self,
+        async_client: AsyncClient,
+        pipeline_factory,
+        printer_factory,
+        library_file_factory,
+        db_session,
+    ):
+        from backend.app.models.pipeline_run import PipelineRun
+
+        printer = await printer_factory()
+        pipeline = await pipeline_factory(target_printer_id=printer.id)
+        src = await library_file_factory()
+        for i in range(3):
+            run = PipelineRun(
+                pipeline_id=pipeline["id"],
+                source_library_file_id=src.id,
+                copies=1,
+                status="completed" if i % 2 == 0 else "failed",
+            )
+            db_session.add(run)
+        await db_session.commit()
+
+        resp = await async_client.get("/api/v1/pipeline-runs?limit=10")
+        assert resp.status_code == 200
+        body = resp.json()
+        assert body["total"] == 3
+        assert len(body["runs"]) == 3
+        # Newest first.
+        assert body["runs"][0]["id"] > body["runs"][-1]["id"]
+
+        # Filter by status.
+        resp = await async_client.get("/api/v1/pipeline-runs?status=failed")
+        body = resp.json()
+        assert all(r["status"] == "failed" for r in body["runs"])
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_retry_failed_creates_child_run(
+        self,
+        async_client: AsyncClient,
+        pipeline_factory,
+        printer_factory,
+        library_file_factory,
+        db_session,
+    ):
+        from dataclasses import dataclass
+
+        from backend.app.models.pipeline_run import PipelineJob, PipelineRun
+
+        @dataclass
+        class _FakeSliceJob:
+            id: int = 6666
+
+        printer = await printer_factory()
+        pipeline = await pipeline_factory(target_printer_id=printer.id)
+        src = await library_file_factory()
+        # Build a parent run with 3 jobs: 1 completed, 2 failed → retry
+        # should request copies=2.
+        parent = PipelineRun(
+            pipeline_id=pipeline["id"],
+            source_library_file_id=src.id,
+            copies=3,
+            status="partial_failure",
+        )
+        db_session.add(parent)
+        await db_session.flush()
+        for idx, status in enumerate(["completed", "failed", "failed"]):
+            db_session.add(PipelineJob(pipeline_run_id=parent.id, copy_index=idx, status=status))
+        await db_session.commit()
+        await db_session.refresh(parent)
+
+        live_status = {"connected": True, "raw_data": {"ams": []}}
+        with (
+            patch(
+                "backend.app.api.routes.pipeline_runs._load_printer_status",
+                new=AsyncMock(return_value=live_status),
+            ),
+            patch(
+                "backend.app.services.slice_dispatch.slice_dispatch.enqueue",
+                new=AsyncMock(return_value=_FakeSliceJob()),
+            ),
+        ):
+            resp = await async_client.post(f"/api/v1/pipeline-runs/{parent.id}/retry-failed")
+        assert resp.status_code == 202, resp.text
+        body = resp.json()
+        assert body["copies"] == 2  # only the 2 failed copies
+        assert body["parent_run_id"] == parent.id
+
+
 class TestCancelTerminal:
 class TestCancelTerminal:
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration

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

@@ -181,6 +181,7 @@ const FR_COGNATES = [
   'Token', 'Server', 'Port', 'Plate', 'Layer', 'Active', 'Total', 'Avatar',
   'Token', 'Server', 'Port', 'Plate', 'Layer', 'Active', 'Total', 'Avatar',
   'Job', 'Modal', 'Pin', 'Pro', 'Mini', 'Studio', 'Excellent', 'Description',
   'Job', 'Modal', 'Pin', 'Pro', 'Mini', 'Studio', 'Excellent', 'Description',
   'Pipeline', 'Pipelines', 'Filament {{n}}',  // #1425 — Slicer Pipelines (FR)
   'Pipeline', 'Pipelines', 'Filament {{n}}',  // #1425 — Slicer Pipelines (FR)
+  'Copies', '{{n}} copies', 'max {{n}}',  // #1425 PR C — French uses these forms verbatim
   'Action', 'Actions', 'Date', 'Type', 'Cache', 'Service', 'Configuration',
   'Action', 'Actions', 'Date', 'Type', 'Cache', 'Service', 'Configuration',
   'Archives', 'Maintenance', 'Notifications', 'Notification', 'Position',
   'Archives', 'Maintenance', 'Notifications', 'Notification', 'Position',
   'Pause', 'Solution', 'Source', 'Version', 'Format', 'Documentation',
   'Pause', 'Solution', 'Source', 'Version', 'Format', 'Documentation',
@@ -220,6 +221,7 @@ const IT_COGNATES = [
   'AMS Filament Backup',  // Bambu Lab product/firmware feature name
   'AMS Filament Backup',  // Bambu Lab product/firmware feature name
   'Email',  // common loanword in Italian, used verbatim in UI labels
   'Email',  // common loanword in Italian, used verbatim in UI labels
   'Pipeline', 'slicing',  // #1425 — Slicer Pipelines (cognate in IT)
   'Pipeline', 'slicing',  // #1425 — Slicer Pipelines (cognate in IT)
+  'max {{n}}',  // #1425 PR C — same form in Italian (max + number)
   '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',

+ 2 - 0
frontend/src/App.tsx

@@ -5,6 +5,7 @@ import { Layout } from './components/Layout';
 import { PrintersPage } from './pages/PrintersPage';
 import { PrintersPage } from './pages/PrintersPage';
 import { ArchivesPage } from './pages/ArchivesPage';
 import { ArchivesPage } from './pages/ArchivesPage';
 import { QueuePage } from './pages/QueuePage';
 import { QueuePage } from './pages/QueuePage';
+import { PipelineRunsPage } from './pages/PipelineRunsPage';
 import { StatsPage } from './pages/StatsPage';
 import { StatsPage } from './pages/StatsPage';
 import { SettingsPage } from './pages/SettingsPage';
 import { SettingsPage } from './pages/SettingsPage';
 import { ProfilesPage } from './pages/ProfilesPage';
 import { ProfilesPage } from './pages/ProfilesPage';
@@ -197,6 +198,7 @@ function App() {
                   <Route index element={<PrintersPage />} />
                   <Route index element={<PrintersPage />} />
                   <Route path="archives" element={<ArchivesPage />} />
                   <Route path="archives" element={<ArchivesPage />} />
                   <Route path="queue" element={<QueuePage />} />
                   <Route path="queue" element={<QueuePage />} />
+                  <Route path="pipelines/runs" element={<PermissionRoute permission="pipelines:read"><PipelineRunsPage /></PermissionRoute>} />
                   <Route path="stats" element={<StatsPage />} />
                   <Route path="stats" element={<StatsPage />} />
                   <Route path="profiles" element={<ProfilesPage />} />
                   <Route path="profiles" element={<ProfilesPage />} />
                   <Route path="maintenance" element={<MaintenancePage />} />
                   <Route path="maintenance" element={<MaintenancePage />} />

+ 3 - 1
frontend/src/__tests__/components/RunWithPipelineModal.test.tsx

@@ -131,6 +131,7 @@ describe('RunWithPipelineModal', () => {
         1,
         1,
         { kind: 'libraryFile', id: 99 },
         { kind: 'libraryFile', id: 99 },
         false,
         false,
+        1,
       );
       );
       expect(onClose).toHaveBeenCalled();
       expect(onClose).toHaveBeenCalled();
     });
     });
@@ -165,6 +166,7 @@ describe('RunWithPipelineModal', () => {
         1,
         1,
         { kind: 'archive', id: 7 },
         { kind: 'archive', id: 7 },
         false,
         false,
+        1,
       );
       );
     });
     });
   });
   });
@@ -202,7 +204,7 @@ describe('RunWithPipelineModal', () => {
     // Clicking Run anyway fires the run with force=true and the new source-kind shape.
     // 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 user.click(screen.getByRole('button', { name: /Run anyway/i }));
     await waitFor(() => {
     await waitFor(() => {
-      expect(mockApi.runPipeline).toHaveBeenCalledWith(1, { kind: 'libraryFile', id: 99 }, true);
+      expect(mockApi.runPipeline).toHaveBeenCalledWith(1, { kind: 'libraryFile', id: 99 }, true, 1);
     });
     });
   });
   });
 });
 });

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

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

+ 3 - 2
frontend/src/__tests__/pages/SettingsPage.test.tsx

@@ -303,7 +303,7 @@ describe('SettingsPage', () => {
 
 
       expect(localStorage.setItem).toHaveBeenCalledWith(
       expect(localStorage.setItem).toHaveBeenCalledWith(
         SIDEBAR_ORDER_KEY,
         SIDEBAR_ORDER_KEY,
-        JSON.stringify(['ext-7', 'printers', 'inventory', 'archives', 'queue', 'projects', 'files', 'makerworld', 'profiles', 'maintenance', 'stats', 'settings']),
+        JSON.stringify(['ext-7', 'printers', 'inventory', 'archives', 'queue', 'pipelineRuns', 'projects', 'files', 'makerworld', 'profiles', 'maintenance', 'stats', 'settings']),
       );
       );
     });
     });
 
 
@@ -345,7 +345,7 @@ describe('SettingsPage', () => {
       expect(localStorage.setItem).toHaveBeenCalledWith(SIDEBAR_HIDDEN_SYSTEM_ITEMS_KEY, JSON.stringify([]));
       expect(localStorage.setItem).toHaveBeenCalledWith(SIDEBAR_HIDDEN_SYSTEM_ITEMS_KEY, JSON.stringify([]));
       expect(localStorage.setItem).toHaveBeenCalledWith(
       expect(localStorage.setItem).toHaveBeenCalledWith(
         SIDEBAR_ORDER_KEY,
         SIDEBAR_ORDER_KEY,
-        JSON.stringify(['printers', 'inventory', 'archives', 'queue', 'projects', 'files', 'makerworld', 'profiles', 'maintenance', 'stats', 'settings', 'ext-7']),
+        JSON.stringify(['printers', 'inventory', 'archives', 'queue', 'pipelineRuns', 'projects', 'files', 'makerworld', 'profiles', 'maintenance', 'stats', 'settings', 'ext-7']),
       );
       );
 
 
       const settingsRow = screen.getAllByText('Settings')
       const settingsRow = screen.getAllByText('Settings')
@@ -410,6 +410,7 @@ describe('SettingsPage', () => {
           'inventory',
           'inventory',
           'archives',
           'archives',
           'queue',
           'queue',
+          'pipelineRuns',
           'projects',
           'projects',
           'files',
           'files',
           'makerworld',
           'makerworld',

+ 48 - 2
frontend/src/api/client.ts

@@ -1139,6 +1139,7 @@ export interface AppSettings {
   spoolman_url: string;
   spoolman_url: string;
   // Default printer
   // Default printer
   default_printer_id: number | null;
   default_printer_id: number | null;
+  pipeline_max_copies: number;
   // Dark mode theme settings
   // Dark mode theme settings
   dark_style: 'classic' | 'glow' | 'vibrant';
   dark_style: 'classic' | 'glow' | 'vibrant';
   dark_background: 'neutral' | 'warm' | 'cool' | 'oled' | 'slate' | 'forest';
   dark_background: 'neutral' | 'warm' | 'cool' | 'oled' | 'slate' | 'forest';
@@ -1535,12 +1536,14 @@ export type SlicerPipelineUpdateRequest = Partial<SlicerPipelineCreateRequest> &
   // ``target_printer_id: 0`` means "clear the target" — the backend maps that
   // ``target_printer_id: 0`` means "clear the target" — the backend maps that
   // to null. Use null in TypeScript for the same intent.
   // to null. Use null in TypeScript for the same intent.
   target_printer_id?: number | null;
   target_printer_id?: number | null;
+  target_model_class?: string | null;
+  fanout_strategy?: 'max_parallel' | 'fill_one_first' | 'round_robin';
 };
 };
 export interface SlicerPipelinesListResponse {
 export interface SlicerPipelinesListResponse {
   pipelines: SlicerPipeline[];
   pipelines: SlicerPipeline[];
 }
 }
 
 
-// Slicer Pipeline runs (#1425 PR B)
+// Slicer Pipeline runs (#1425 PR B + PR C)
 export type PipelineEligibilityKind =
 export type PipelineEligibilityKind =
   | 'printer_not_set'
   | 'printer_not_set'
   | 'printer_not_found'
   | 'printer_not_found'
@@ -1549,18 +1552,29 @@ export type PipelineEligibilityKind =
   | 'filament_type_mismatch'
   | 'filament_type_mismatch'
   | 'filament_color_mismatch'
   | 'filament_color_mismatch'
   | 'ams_slot_missing'
   | 'ams_slot_missing'
-  | 'filament_unverified';
+  | 'filament_unverified'
+  | 'no_class_matches'
+  | 'class_not_set';
 export interface PipelineEligibilityIssue {
 export interface PipelineEligibilityIssue {
   kind: PipelineEligibilityKind;
   kind: PipelineEligibilityKind;
   slot_index: number | null;
   slot_index: number | null;
   expected: string | null;
   expected: string | null;
   actual: string | null;
   actual: string | null;
 }
 }
+export interface PipelinePerPrinterReport {
+  printer_id: number;
+  printer_name: string;
+  ok: boolean;
+  issues: PipelineEligibilityIssue[];
+}
 export interface PipelineEligibilityReport {
 export interface PipelineEligibilityReport {
   ok: boolean;
   ok: boolean;
+  target_kind: 'specific_printer' | 'printer_class';
   target_printer_id: number | null;
   target_printer_id: number | null;
   target_printer_name: string | null;
   target_printer_name: string | null;
+  target_model_class: string | null;
   issues: PipelineEligibilityIssue[];
   issues: PipelineEligibilityIssue[];
+  printer_reports: PipelinePerPrinterReport[];
 }
 }
 export interface PipelineJob {
 export interface PipelineJob {
   id: number;
   id: number;
@@ -1588,7 +1602,12 @@ export interface PipelineRun {
   source_library_file_id: number | null;
   source_library_file_id: number | null;
   source_archive_id: number | null;
   source_archive_id: number | null;
   source_filename: string | null;
   source_filename: string | null;
+  parent_run_id: number | null;
   copies: number;
   copies: number;
+  copies_completed: number;
+  copies_failed: number;
+  copies_cancelled: number;
+  copies_in_progress: number;
   status:
   status:
     | 'queued'
     | 'queued'
     | 'slicing'
     | 'slicing'
@@ -1596,6 +1615,7 @@ export interface PipelineRun {
     | 'in_progress'
     | 'in_progress'
     | 'completed'
     | 'completed'
     | 'failed'
     | 'failed'
+    | 'partial_failure'
     | 'cancelled';
     | 'cancelled';
   slice_job_id: number | null;
   slice_job_id: number | null;
   sliced_library_file_id: number | null;
   sliced_library_file_id: number | null;
@@ -1606,9 +1626,14 @@ export interface PipelineRun {
   started_at: string | null;
   started_at: string | null;
   completed_at: string | null;
   completed_at: string | null;
   jobs: PipelineJob[];
   jobs: PipelineJob[];
+  target_kind: 'specific_printer' | 'printer_class' | null;
+  target_printer_id: number | null;
+  target_model_class: string | null;
+  fanout_strategy: 'max_parallel' | 'fill_one_first' | 'round_robin' | null;
 }
 }
 export interface PipelineRunListResponse {
 export interface PipelineRunListResponse {
   runs: PipelineRun[];
   runs: PipelineRun[];
+  total: number;
 }
 }
 
 
 export interface SliceResponse {
 export interface SliceResponse {
@@ -6343,6 +6368,7 @@ export const api = {
     pipelineId: number,
     pipelineId: number,
     source: { kind: 'libraryFile'; id: number } | { kind: 'archive'; id: number },
     source: { kind: 'libraryFile'; id: number } | { kind: 'archive'; id: number },
     force = false,
     force = false,
+    copies = 1,
   ) =>
   ) =>
     request<PipelineRun>(`/slicer-pipelines/${pipelineId}/run`, {
     request<PipelineRun>(`/slicer-pipelines/${pipelineId}/run`, {
       method: 'POST',
       method: 'POST',
@@ -6351,16 +6377,36 @@ export const api = {
           ? { source_library_file_id: source.id }
           ? { source_library_file_id: source.id }
           : { source_archive_id: source.id }),
           : { source_archive_id: source.id }),
         force,
         force,
+        copies,
       }),
       }),
     }),
     }),
   listPipelineRuns: (pipelineId: number, limit = 5) =>
   listPipelineRuns: (pipelineId: number, limit = 5) =>
     request<PipelineRunListResponse>(
     request<PipelineRunListResponse>(
       `/slicer-pipelines/${pipelineId}/runs?limit=${limit}`,
       `/slicer-pipelines/${pipelineId}/runs?limit=${limit}`,
     ),
     ),
+  // Dashboard list across all pipelines (#1425 PR C).
+  listAllPipelineRuns: (params: {
+    limit?: number;
+    offset?: number;
+    pipelineId?: number;
+    status?: string;
+  } = {}) => {
+    const search = new URLSearchParams();
+    if (params.limit) search.set('limit', String(params.limit));
+    if (params.offset) search.set('offset', String(params.offset));
+    if (params.pipelineId) search.set('pipeline_id', String(params.pipelineId));
+    if (params.status) search.set('status', params.status);
+    const q = search.toString();
+    return request<PipelineRunListResponse>(
+      `/pipeline-runs${q ? '?' + q : ''}`,
+    );
+  },
   getPipelineRun: (runId: number) =>
   getPipelineRun: (runId: number) =>
     request<PipelineRun>(`/pipeline-runs/${runId}`),
     request<PipelineRun>(`/pipeline-runs/${runId}`),
   cancelPipelineRun: (runId: number) =>
   cancelPipelineRun: (runId: number) =>
     request<PipelineRun>(`/pipeline-runs/${runId}/cancel`, { method: 'POST' }),
     request<PipelineRun>(`/pipeline-runs/${runId}/cancel`, { method: 'POST' }),
+  retryFailedPipelineRun: (runId: number) =>
+    request<PipelineRun>(`/pipeline-runs/${runId}/retry-failed`, { method: 'POST' }),
 
 
   // Canonical Bambu printer-model registry — "Bambu Lab <model>" → short code.
   // Canonical Bambu printer-model registry — "Bambu Lab <model>" → short code.
   // Single source of truth shared with backend (PRINTER_MODEL_MAP); the
   // Single source of truth shared with backend (PRINTER_MODEL_MAP); the

+ 2 - 1
frontend/src/components/Layout.tsx

@@ -1,6 +1,6 @@
 import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
 import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
 import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom';
 import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom';
-import { Printer, Archive, ListOrdered, BarChart3, Cloud, Settings, Sun, Moon, Monitor, ChevronLeft, ChevronRight, Keyboard, Github, ArrowUpCircle, Wrench, FolderKanban, FolderOpen, X, Menu, Info, Plug, Bug, LogOut, Key, Loader2, Disc3, ShieldAlert, Globe, type LucideIcon } from 'lucide-react';
+import { Printer, Archive, ListOrdered, BarChart3, Cloud, Settings, Sun, Moon, Monitor, ChevronLeft, ChevronRight, Keyboard, Github, ArrowUpCircle, Wrench, FolderKanban, FolderOpen, X, Menu, Info, Plug, Bug, LogOut, Key, Loader2, Disc3, ShieldAlert, Globe, Workflow, type LucideIcon } from 'lucide-react';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 import { useTheme } from '../contexts/ThemeContext';
 import { useTheme } from '../contexts/ThemeContext';
 import { KeyboardShortcutsModal } from './KeyboardShortcutsModal';
 import { KeyboardShortcutsModal } from './KeyboardShortcutsModal';
@@ -42,6 +42,7 @@ export const defaultNavItems: NavItem[] = [
   { id: 'inventory', to: '/inventory', icon: Disc3, labelKey: 'nav.inventory' },
   { id: 'inventory', to: '/inventory', icon: Disc3, labelKey: 'nav.inventory' },
   { id: 'archives', to: '/archives', icon: Archive, labelKey: 'nav.archives' },
   { id: 'archives', to: '/archives', icon: Archive, labelKey: 'nav.archives' },
   { id: 'queue', to: '/queue', icon: ListOrdered, labelKey: 'nav.queue' },
   { id: 'queue', to: '/queue', icon: ListOrdered, labelKey: 'nav.queue' },
+  { id: 'pipelineRuns', to: '/pipelines/runs', icon: Workflow, labelKey: 'nav.pipelineRuns' },
   { id: 'projects', to: '/projects', icon: FolderKanban, labelKey: 'nav.projects' },
   { id: 'projects', to: '/projects', icon: FolderKanban, labelKey: 'nav.projects' },
   { id: 'files', to: '/files', icon: FolderOpen, labelKey: 'nav.files' },
   { id: 'files', to: '/files', icon: FolderOpen, labelKey: 'nav.files' },
   { id: 'makerworld', to: '/makerworld', icon: Globe, labelKey: 'nav.makerworld' },
   { id: 'makerworld', to: '/makerworld', icon: Globe, labelKey: 'nav.makerworld' },

+ 53 - 4
frontend/src/components/RunWithPipelineModal.tsx

@@ -34,6 +34,7 @@ export function RunWithPipelineModal({ source, onClose }: RunWithPipelineModalPr
 
 
   const [picked, setPicked] = useState<SlicerPipeline | null>(null);
   const [picked, setPicked] = useState<SlicerPipeline | null>(null);
   const [report, setReport] = useState<PipelineEligibilityReport | null>(null);
   const [report, setReport] = useState<PipelineEligibilityReport | null>(null);
+  const [copies, setCopies] = useState<number>(1);
   const { trackJob } = useSliceJobTracker();
   const { trackJob } = useSliceJobTracker();
 
 
   const { data: list, isLoading: pipelinesLoading } = useQuery({
   const { data: list, isLoading: pipelinesLoading } = useQuery({
@@ -44,6 +45,13 @@ export function RunWithPipelineModal({ source, onClose }: RunWithPipelineModalPr
     queryKey: ['printers'],
     queryKey: ['printers'],
     queryFn: () => api.getPrinters(),
     queryFn: () => api.getPrinters(),
   });
   });
+  // Cap from settings (PR C). Falls back to 50 when the fetch is in-flight or
+  // missing — same default the backend writes.
+  const { data: settings } = useQuery({
+    queryKey: ['app-settings'],
+    queryFn: () => api.getSettings(),
+  });
+  const maxCopies = settings?.pipeline_max_copies ?? 50;
 
 
   const sourceRef = { kind: source.kind, id: source.id } as const;
   const sourceRef = { kind: source.kind, id: source.id } as const;
   const checkMutation = useMutation({
   const checkMutation = useMutation({
@@ -53,7 +61,7 @@ export function RunWithPipelineModal({ source, onClose }: RunWithPipelineModalPr
 
 
   const runMutation = useMutation({
   const runMutation = useMutation({
     mutationFn: ({ pipelineId, force }: { pipelineId: number; force: boolean }) =>
     mutationFn: ({ pipelineId, force }: { pipelineId: number; force: boolean }) =>
-      api.runPipeline(pipelineId, sourceRef, force),
+      api.runPipeline(pipelineId, sourceRef, force, copies),
     onSuccess: (run) => {
     onSuccess: (run) => {
       queryClient.invalidateQueries({ queryKey: ['slicer-pipelines'] });
       queryClient.invalidateQueries({ queryKey: ['slicer-pipelines'] });
       queryClient.invalidateQueries({ queryKey: ['pipeline-runs'] });
       queryClient.invalidateQueries({ queryKey: ['pipeline-runs'] });
@@ -78,7 +86,10 @@ export function RunWithPipelineModal({ source, onClose }: RunWithPipelineModalPr
   }, {} as Record<number, PrinterType>);
   }, {} as Record<number, PrinterType>);
 
 
   const handlePick = async (pipeline: SlicerPipeline) => {
   const handlePick = async (pipeline: SlicerPipeline) => {
-    if (!pipeline.target_printer_id) {
+    const hasTarget =
+      pipeline.target_printer_id ||
+      (pipeline.target_kind === 'printer_class' && pipeline.target_model_class);
+    if (!hasTarget) {
       showToast(
       showToast(
         t('library.runWithPipeline.noTargetMessage', 'This pipeline has no target printer set. Open it in Settings to pick one.'),
         t('library.runWithPipeline.noTargetMessage', 'This pipeline has no target printer set. Open it in Settings to pick one.'),
         'error',
         'error',
@@ -154,6 +165,9 @@ export function RunWithPipelineModal({ source, onClose }: RunWithPipelineModalPr
               printerById={printerById}
               printerById={printerById}
               loading={pipelinesLoading || checkMutation.isPending}
               loading={pipelinesLoading || checkMutation.isPending}
               onPick={handlePick}
               onPick={handlePick}
+              copies={copies}
+              maxCopies={maxCopies}
+              onCopiesChange={setCopies}
             />
             />
           )}
           )}
         </div>
         </div>
@@ -168,12 +182,18 @@ function PickStep({
   printerById,
   printerById,
   loading,
   loading,
   onPick,
   onPick,
+  copies,
+  maxCopies,
+  onCopiesChange,
 }: {
 }: {
   source: { filename: string };
   source: { filename: string };
   pipelines: SlicerPipeline[];
   pipelines: SlicerPipeline[];
   printerById: Record<number, { name: string }>;
   printerById: Record<number, { name: string }>;
   loading: boolean;
   loading: boolean;
   onPick: (p: SlicerPipeline) => void;
   onPick: (p: SlicerPipeline) => void;
+  copies: number;
+  maxCopies: number;
+  onCopiesChange: (n: number) => void;
 }) {
 }) {
   const { t } = useTranslation();
   const { t } = useTranslation();
   return (
   return (
@@ -182,6 +202,28 @@ function PickStep({
         {t('library.runWithPipeline.sourceHint', 'Source')}:{' '}
         {t('library.runWithPipeline.sourceHint', 'Source')}:{' '}
         <span className="text-white">{source.filename}</span>
         <span className="text-white">{source.filename}</span>
       </p>
       </p>
+      <div className="flex items-center gap-2">
+        <label className="text-xs text-bambu-gray" htmlFor="run-pipeline-copies">
+          {t('library.runWithPipeline.copies', 'Copies')}:
+        </label>
+        <input
+          id="run-pipeline-copies"
+          type="number"
+          min={1}
+          max={maxCopies}
+          value={copies}
+          onChange={(e) => {
+            const n = parseInt(e.target.value, 10);
+            if (Number.isNaN(n)) return;
+            onCopiesChange(Math.max(1, Math.min(maxCopies, n)));
+          }}
+          aria-label={t('library.runWithPipeline.copies', 'Copies')}
+          className="w-20 px-2 py-1 text-xs bg-bambu-dark border border-bambu-dark-tertiary rounded text-white"
+        />
+        <span className="text-xs text-bambu-gray/60">
+          {t('library.runWithPipeline.copiesHint', 'max {{n}}', { n: maxCopies })}
+        </span>
+      </div>
       {loading && (
       {loading && (
         <div className="flex items-center gap-2 text-sm text-bambu-gray">
         <div className="flex items-center gap-2 text-sm text-bambu-gray">
           <Loader2 className="w-4 h-4 animate-spin" />
           <Loader2 className="w-4 h-4 animate-spin" />
@@ -199,13 +241,18 @@ function PickStep({
       {!loading && pipelines.length > 0 && (
       {!loading && pipelines.length > 0 && (
         <ul className="space-y-1.5" aria-label={t('library.runWithPipeline.pipelineListAria', 'Available pipelines')}>
         <ul className="space-y-1.5" aria-label={t('library.runWithPipeline.pipelineListAria', 'Available pipelines')}>
           {pipelines.map((p) => {
           {pipelines.map((p) => {
+            const isClass = p.target_kind === 'printer_class';
             const targetName = p.target_printer_id ? printerById[p.target_printer_id]?.name : null;
             const targetName = p.target_printer_id ? printerById[p.target_printer_id]?.name : null;
+            const classLabel = isClass && p.target_model_class
+              ? t('library.runWithPipeline.classTarget', 'Any {{model}}', { model: p.target_model_class })
+              : null;
+            const hasTarget = !!(p.target_printer_id || (isClass && p.target_model_class));
             return (
             return (
               <li key={p.id}>
               <li key={p.id}>
                 <button
                 <button
                   type="button"
                   type="button"
                   onClick={() => onPick(p)}
                   onClick={() => onPick(p)}
-                  disabled={!p.target_printer_id}
+                  disabled={!hasTarget}
                   className="w-full text-left px-3 py-2 rounded-md border border-bambu-dark-tertiary bg-bambu-dark/40 hover:bg-bambu-dark-tertiary disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
                   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">
                   <div className="flex items-center gap-2">
@@ -214,7 +261,9 @@ function PickStep({
                   </div>
                   </div>
                   <div className="mt-1 text-xs text-bambu-gray flex items-center gap-1">
                   <div className="mt-1 text-xs text-bambu-gray flex items-center gap-1">
                     <PrinterIcon className="w-3 h-3" />
                     <PrinterIcon className="w-3 h-3" />
-                    {targetName ? (
+                    {classLabel ? (
+                      <span>{classLabel}</span>
+                    ) : targetName ? (
                       <span>{targetName}</span>
                       <span>{targetName}</span>
                     ) : (
                     ) : (
                       <span className="text-amber-400">
                       <span className="text-amber-400">

+ 146 - 23
frontend/src/components/SlicerPipelinesPanel.tsx

@@ -171,6 +171,8 @@ function PipelineRow({
     description?: string | null;
     description?: string | null;
     target_printer_id?: number | null;
     target_printer_id?: number | null;
     target_kind?: 'specific_printer' | 'printer_class';
     target_kind?: 'specific_printer' | 'printer_class';
+    target_model_class?: string | null;
+    fanout_strategy?: 'max_parallel' | 'fill_one_first' | 'round_robin';
   }) => void;
   }) => void;
   onDelete: () => void;
   onDelete: () => void;
   saving: boolean;
   saving: boolean;
@@ -183,6 +185,22 @@ function PipelineRow({
   const [draftTargetPrinterId, setDraftTargetPrinterId] = useState<number | null>(
   const [draftTargetPrinterId, setDraftTargetPrinterId] = useState<number | null>(
     pipeline.target_printer_id,
     pipeline.target_printer_id,
   );
   );
+  // PR C: target kind, model class, and fanout strategy.
+  const [draftTargetKind, setDraftTargetKind] = useState<'specific_printer' | 'printer_class'>(
+    pipeline.target_kind === 'printer_class' ? 'printer_class' : 'specific_printer',
+  );
+  const [draftTargetModelClass, setDraftTargetModelClass] = useState<string>(
+    pipeline.target_model_class ?? '',
+  );
+  const [draftFanout, setDraftFanout] = useState<'max_parallel' | 'fill_one_first' | 'round_robin'>(
+    pipeline.fanout_strategy ?? 'max_parallel',
+  );
+  // Installed model classes — derived from the loaded printers list so the
+  // dropdown only offers models the user actually has. Same data the row
+  // header uses, no second fetch.
+  const installedModels = Array.from(
+    new Set(printers.map((p) => p.model).filter((m): m is string => !!m)),
+  ).sort();
 
 
   // Recent runs for the inline last-run summary. ``enabled: editing === false``
   // Recent runs for the inline last-run summary. ``enabled: editing === false``
   // avoids re-querying every keystroke while the editor is open.
   // avoids re-querying every keystroke while the editor is open.
@@ -203,7 +221,10 @@ function PipelineRow({
   const targetPrinter = pipeline.target_printer_id
   const targetPrinter = pipeline.target_printer_id
     ? printers.find((p) => p.id === pipeline.target_printer_id)
     ? printers.find((p) => p.id === pipeline.target_printer_id)
     : undefined;
     : undefined;
-  const needsTarget = pipeline.target_printer_id === null;
+  const isClassTargeting = pipeline.target_kind === 'printer_class';
+  const needsTarget = isClassTargeting
+    ? !pipeline.target_model_class
+    : pipeline.target_printer_id === null;
 
 
   const handleSave = () => {
   const handleSave = () => {
     const trimmedName = draftName.trim();
     const trimmedName = draftName.trim();
@@ -211,9 +232,13 @@ function PipelineRow({
     onSave({
     onSave({
       name: trimmedName,
       name: trimmedName,
       description: draftDescription.trim() || null,
       description: draftDescription.trim() || null,
-      target_kind: 'specific_printer',
+      target_kind: draftTargetKind,
       // Backend treats 0 as "clear"; null in TS maps to that intent.
       // Backend treats 0 as "clear"; null in TS maps to that intent.
-      target_printer_id: draftTargetPrinterId ?? 0,
+      target_printer_id:
+        draftTargetKind === 'specific_printer' ? (draftTargetPrinterId ?? 0) : 0,
+      target_model_class:
+        draftTargetKind === 'printer_class' ? (draftTargetModelClass || null) : null,
+      fanout_strategy: draftFanout,
     });
     });
     setEditing(false);
     setEditing(false);
   };
   };
@@ -222,6 +247,9 @@ function PipelineRow({
     setDraftName(pipeline.name);
     setDraftName(pipeline.name);
     setDraftDescription(pipeline.description ?? '');
     setDraftDescription(pipeline.description ?? '');
     setDraftTargetPrinterId(pipeline.target_printer_id);
     setDraftTargetPrinterId(pipeline.target_printer_id);
+    setDraftTargetKind(pipeline.target_kind === 'printer_class' ? 'printer_class' : 'specific_printer');
+    setDraftTargetModelClass(pipeline.target_model_class ?? '');
+    setDraftFanout(pipeline.fanout_strategy ?? 'max_parallel');
     setEditing(false);
     setEditing(false);
   };
   };
 
 
@@ -246,28 +274,110 @@ 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"
               />
               />
+              {/* PR C — target-kind radio (Specific printer / Printer class)
+                  drives whether the printer dropdown or the class picker is
+                  active. Both fields are kept on state so toggling back and
+                  forth doesn't lose the user's previous pick. */}
               <div>
               <div>
                 <label className="text-xs text-bambu-gray block mb-1">
                 <label className="text-xs text-bambu-gray block mb-1">
-                  {t('settings.pipelines.field.targetPrinter', 'Target printer')}
+                  {t('settings.pipelines.field.targetKind', 'Target type')}
                 </label>
                 </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 className="flex gap-3 text-xs">
+                  <label className="flex items-center gap-1 text-white">
+                    <input
+                      type="radio"
+                      name={`target-kind-${pipeline.id}`}
+                      value="specific_printer"
+                      checked={draftTargetKind === 'specific_printer'}
+                      onChange={() => setDraftTargetKind('specific_printer')}
+                      aria-label={t('settings.pipelines.field.targetKindSpecific', 'Specific printer')}
+                    />
+                    {t('settings.pipelines.field.targetKindSpecific', 'Specific printer')}
+                  </label>
+                  <label className="flex items-center gap-1 text-white">
+                    <input
+                      type="radio"
+                      name={`target-kind-${pipeline.id}`}
+                      value="printer_class"
+                      checked={draftTargetKind === 'printer_class'}
+                      onChange={() => setDraftTargetKind('printer_class')}
+                      aria-label={t('settings.pipelines.field.targetKindClass', 'Printer class')}
+                    />
+                    {t('settings.pipelines.field.targetKindClass', 'Printer class')}
+                  </label>
+                </div>
               </div>
               </div>
+
+              {draftTargetKind === 'specific_printer' ? (
+                <div>
+                  <label className="text-xs text-bambu-gray block mb-1">
+                    {t('settings.pipelines.field.targetPrinter', 'Target printer')}
+                  </label>
+                  <select
+                    value={draftTargetPrinterId ?? ''}
+                    onChange={(e) =>
+                      setDraftTargetPrinterId(e.target.value ? parseInt(e.target.value, 10) : null)
+                    }
+                    aria-label={t('settings.pipelines.field.targetPrinter', 'Target printer')}
+                    className="w-full px-2 py-1 text-xs bg-bambu-dark border border-bambu-dark-tertiary rounded text-white"
+                  >
+                    <option value="">
+                      {t('settings.pipelines.field.noTarget', '— No target —')}
+                    </option>
+                    {printers.map((p) => (
+                      <option key={p.id} value={p.id}>
+                        {p.name}
+                      </option>
+                    ))}
+                  </select>
+                </div>
+              ) : (
+                <div className="space-y-2">
+                  <div>
+                    <label className="text-xs text-bambu-gray block mb-1">
+                      {t('settings.pipelines.field.targetModelClass', 'Printer model')}
+                    </label>
+                    <select
+                      value={draftTargetModelClass}
+                      onChange={(e) => setDraftTargetModelClass(e.target.value)}
+                      aria-label={t('settings.pipelines.field.targetModelClass', 'Printer model')}
+                      className="w-full px-2 py-1 text-xs bg-bambu-dark border border-bambu-dark-tertiary rounded text-white"
+                    >
+                      <option value="">
+                        {t('settings.pipelines.field.noTarget', '— No target —')}
+                      </option>
+                      {installedModels.map((m) => (
+                        <option key={m} value={m}>
+                          {m}
+                        </option>
+                      ))}
+                    </select>
+                  </div>
+                  <div>
+                    <label className="text-xs text-bambu-gray block mb-1">
+                      {t('settings.pipelines.field.fanoutStrategy', 'Fanout strategy')}
+                    </label>
+                    <select
+                      value={draftFanout}
+                      onChange={(e) =>
+                        setDraftFanout(e.target.value as 'max_parallel' | 'fill_one_first' | 'round_robin')
+                      }
+                      aria-label={t('settings.pipelines.field.fanoutStrategy', 'Fanout strategy')}
+                      className="w-full px-2 py-1 text-xs bg-bambu-dark border border-bambu-dark-tertiary rounded text-white"
+                    >
+                      <option value="max_parallel">
+                        {t('settings.pipelines.field.fanout.max_parallel', 'Max parallel — distribute across any idle matching printer')}
+                      </option>
+                      <option value="round_robin">
+                        {t('settings.pipelines.field.fanout.round_robin', 'Round robin — cycle through eligible printers')}
+                      </option>
+                      <option value="fill_one_first">
+                        {t('settings.pipelines.field.fanout.fill_one_first', 'Fill one first — pin all copies to one printer')}
+                      </option>
+                    </select>
+                  </div>
+                </div>
+              )}
             </div>
             </div>
           ) : (
           ) : (
             <>
             <>
@@ -354,9 +464,21 @@ function PipelineRow({
           <div className="text-bambu-gray flex items-center gap-1">
           <div className="text-bambu-gray flex items-center gap-1">
             <PrinterIcon className="w-3 h-3" />
             <PrinterIcon className="w-3 h-3" />
             <span className="font-medium text-bambu-gray/80">
             <span className="font-medium text-bambu-gray/80">
-              {t('settings.pipelines.field.targetPrinter', 'Target printer')}:
+              {isClassTargeting
+                ? t('settings.pipelines.field.targetModelClass', 'Printer model')
+                : t('settings.pipelines.field.targetPrinter', 'Target printer')}
+              :
             </span>{' '}
             </span>{' '}
-            {targetPrinter ? (
+            {isClassTargeting && pipeline.target_model_class ? (
+              <span className="text-white">
+                {pipeline.target_model_class}
+                {pipeline.fanout_strategy && (
+                  <span className="text-bambu-gray/60">
+                    {' '}· {t(`settings.pipelines.field.fanout.${pipeline.fanout_strategy}`, pipeline.fanout_strategy)}
+                  </span>
+                )}
+              </span>
+            ) : targetPrinter ? (
               <span className="text-white">{targetPrinter.name}</span>
               <span className="text-white">{targetPrinter.name}</span>
             ) : (
             ) : (
               <span className="text-amber-400">
               <span className="text-amber-400">
@@ -413,6 +535,7 @@ function RunStatusBadge({ status }: { status: PipelineRun['status'] }) {
     in_progress: 'text-bambu-green',
     in_progress: 'text-bambu-green',
     completed: 'text-bambu-green',
     completed: 'text-bambu-green',
     failed: 'text-red-400',
     failed: 'text-red-400',
+    partial_failure: 'text-amber-400',
     cancelled: 'text-bambu-gray',
     cancelled: 'text-bambu-gray',
   };
   };
   return (
   return (

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

@@ -11,6 +11,10 @@ interface WebSocketMessage {
   data?: Record<string, unknown>;
   data?: Record<string, unknown>;
   printer_name?: string;
   printer_name?: string;
   missing_slots?: Array<{ slot?: string }>;
   missing_slots?: Array<{ slot?: string }>;
+  // Slicer Pipeline run events (#1425 PR C). ``run`` carries the full
+  // PipelineRunResponse payload — typed loosely here so the WebSocket hook
+  // doesn't pull the full client.ts types in.
+  run?: { pipeline_id?: number | null };
 }
 }
 
 
 export function useWebSocket() {
 export function useWebSocket() {
@@ -399,6 +403,15 @@ export function useWebSocket() {
       case 'queue_item_failed':
       case 'queue_item_failed':
         window.dispatchEvent(new CustomEvent('bambuddy:dispatch-toast', { detail: message }));
         window.dispatchEvent(new CustomEvent('bambuddy:dispatch-toast', { detail: message }));
         break;
         break;
+      // Slicer Pipeline runs (#1425 PR C). State transitions on the run
+      // refresh both the dashboard list AND the per-pipeline "Last run"
+      // chip in Settings → Pipelines.
+      case 'pipeline_run_updated':
+        queryClient.invalidateQueries({ queryKey: ['pipeline-runs-all'] });
+        if (message.run?.pipeline_id) {
+          queryClient.invalidateQueries({ queryKey: ['pipeline-runs', message.run.pipeline_id] });
+        }
+        break;
     }
     }
   }, [queryClient, debouncedInvalidate, throttledPrinterStatusUpdate, showToast, t]);
   }, [queryClient, debouncedInvalidate, throttledPrinterStatusUpdate, showToast, t]);
 
 

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

@@ -4,6 +4,7 @@ export default {
     printers: 'Drucker',
     printers: 'Drucker',
     archives: 'Archiv',
     archives: 'Archiv',
     queue: 'Druckwarteschlange',
     queue: 'Druckwarteschlange',
+    pipelineRuns: 'Pipeline-Läufe',
     stats: 'Statistiken',
     stats: 'Statistiken',
     profiles: 'Profile',
     profiles: 'Profile',
     maintenance: 'Wartung',
     maintenance: 'Wartung',
@@ -101,6 +102,8 @@ export default {
     now: 'Jetzt',
     now: 'Jetzt',
     collapse: 'Einklappen',
     collapse: 'Einklappen',
     expand: 'Ausklappen',
     expand: 'Ausklappen',
+    previous: 'Zurück',
+    next: 'Weiter',
     viewArchive: 'Archiv anzeigen',
     viewArchive: 'Archiv anzeigen',
     viewInFileManager: 'Im Dateimanager anzeigen',
     viewInFileManager: 'Im Dateimanager anzeigen',
     addedBy: 'Hinzugefügt von {{username}}',
     addedBy: 'Hinzugefügt von {{username}}',
@@ -1044,6 +1047,39 @@ export default {
     dismiss: 'Schließen',
     dismiss: 'Schließen',
   },
   },
 
 
+  // Pipeline Runs dashboard (#1425 PR C).
+  pipelineRuns: {
+    title: 'Pipeline-Läufe',
+    loading: 'Wird geladen…',
+    empty: 'Noch keine Pipeline-Läufe.',
+    filter: {
+      pipeline: 'Pipeline',
+      status: 'Status',
+      all: 'Alle',
+    },
+    copies: '{{n}} Kopien',
+    failedCount: '{{n}} fehlgeschlagen',
+    copyN: 'Kopie {{n}}',
+    retryFailed: 'Fehlgeschlagene wiederholen',
+    retryOf: 'Wiederholung von #{{n}}',
+    pagination: '{{start}}–{{end}} von {{total}}',
+    toast: {
+      cancelled: 'Lauf abgebrochen',
+      cancelFailed: 'Abbruch fehlgeschlagen',
+      retryStarted: 'Wiederholung gestartet',
+      retryFailed: 'Wiederholung fehlgeschlagen',
+    },
+    jobStatus: {
+      pending: 'ausstehend',
+      awaiting_printer: 'wartet auf Drucker',
+      queued: 'in Warteschlange',
+      printing: 'druckt',
+      completed: 'abgeschlossen',
+      failed: 'fehlgeschlagen',
+      cancelled: 'abgebrochen',
+    },
+  },
+
   // Queue page
   // Queue page
   queue: {
   queue: {
     filamentShort: {
     filamentShort: {
@@ -2554,6 +2590,12 @@ export default {
     },
     },
 
 
 
 
+    pipelineLimits: {
+      title: 'Slicer-Pipeline-Limits',
+      maxCopiesLabel: 'Max. Kopien pro Lauf',
+      maxCopiesDesc: 'Obergrenze für die Anzahl an Kopien, die Operatoren beim Ausführen einer Pipeline anfordern können. Serverseitige Obergrenze ist 1000.',
+    },
+
     // Slicer Pipelines (#1425): list/edit/delete preset bundles users saved
     // Slicer Pipelines (#1425): list/edit/delete preset bundles users saved
     // from the Slice dialog. Lives in Settings → Workflow → Pipelines sub-tab.
     // from the Slice dialog. Lives in Settings → Workflow → Pipelines sub-tab.
     pipelines: {
     pipelines: {
@@ -2572,6 +2614,16 @@ export default {
         description: 'Beschreibung',
         description: 'Beschreibung',
         targetPrinter: 'Zieldrucker',
         targetPrinter: 'Zieldrucker',
         noTarget: '— Kein Ziel —',
         noTarget: '— Kein Ziel —',
+        targetKind: 'Zielart',
+        targetKindSpecific: 'Spezifischer Drucker',
+        targetKindClass: 'Druckerklasse',
+        targetModelClass: 'Druckermodell',
+        fanoutStrategy: 'Verteilungsstrategie',
+        fanout: {
+          max_parallel: 'Max parallel — auf alle verfügbaren passenden Drucker verteilen',
+          round_robin: 'Reihum — durch geeignete Drucker rotieren',
+          fill_one_first: 'Erst einen füllen — alle Kopien an einen Drucker binden',
+        },
       },
       },
       action: {
       action: {
         save: 'Speichern',
         save: 'Speichern',
@@ -2603,6 +2655,7 @@ export default {
           in_progress: 'druckt',
           in_progress: 'druckt',
           completed: 'abgeschlossen',
           completed: 'abgeschlossen',
           failed: 'fehlgeschlagen',
           failed: 'fehlgeschlagen',
+          partial_failure: 'teilweise fehlgeschlagen',
           cancelled: 'abgebrochen',
           cancelled: 'abgebrochen',
         },
         },
       },
       },
@@ -3851,6 +3904,9 @@ export default {
       empty: 'Noch keine Pipelines gespeichert. Öffne den Slice-Dialog und klicke „Als Pipeline speichern“, um eine zu erstellen.',
       empty: 'Noch keine Pipelines gespeichert. Öffne den Slice-Dialog und klicke „Als Pipeline speichern“, um eine zu erstellen.',
       noTarget: 'Kein Zieldrucker festgelegt',
       noTarget: 'Kein Zieldrucker festgelegt',
       noTargetMessage: 'Diese Pipeline hat keinen Zieldrucker. Öffne sie in den Einstellungen, um einen festzulegen.',
       noTargetMessage: 'Diese Pipeline hat keinen Zieldrucker. Öffne sie in den Einstellungen, um einen festzulegen.',
+      copies: 'Kopien',
+      copiesHint: 'max. {{n}}',
+      classTarget: 'Beliebiger {{model}}',
       toast: {
       toast: {
         started: 'Pipeline-Lauf gestartet',
         started: 'Pipeline-Lauf gestartet',
         failed: 'Lauf konnte nicht gestartet werden',
         failed: 'Lauf konnte nicht gestartet werden',
@@ -3864,6 +3920,8 @@ export default {
         filamentColor: 'Filament-Slot {{slot}}: Farbe weicht ab (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',
         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.',
         filamentUnverified: 'Filament-Slot {{slot}} stammt aus einem Cloud-/Standard-Preset und konnte nicht statisch verifiziert werden.',
+        noClassMatches: 'Keine Drucker in dieser Installation entsprechen der Zielmodellklasse der Pipeline ({{expected}}).',
+        classNotSet: 'Pipeline-Ziel ist auf eine Druckerklasse gesetzt, aber kein Modell wurde gewählt.',
       },
       },
     },
     },
   },
   },

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

@@ -4,6 +4,7 @@ export default {
     printers: 'Printers',
     printers: 'Printers',
     archives: 'Archives',
     archives: 'Archives',
     queue: 'Print Queue',
     queue: 'Print Queue',
+    pipelineRuns: 'Pipeline Runs',
     stats: 'Statistics',
     stats: 'Statistics',
     profiles: 'Profiles',
     profiles: 'Profiles',
     maintenance: 'Maintenance',
     maintenance: 'Maintenance',
@@ -101,6 +102,8 @@ export default {
     now: 'Now',
     now: 'Now',
     collapse: 'Collapse',
     collapse: 'Collapse',
     expand: 'Expand',
     expand: 'Expand',
+    previous: 'Previous',
+    next: 'Next',
     viewArchive: 'View archive',
     viewArchive: 'View archive',
     viewInFileManager: 'View in File Manager',
     viewInFileManager: 'View in File Manager',
     addedBy: 'Added by {{username}}',
     addedBy: 'Added by {{username}}',
@@ -1050,6 +1053,42 @@ export default {
     dismiss: 'Dismiss',
     dismiss: 'Dismiss',
   },
   },
 
 
+  // Pipeline Runs dashboard (#1425 PR C). Lists every Slicer Pipeline run
+  // across every pipeline with status + pipeline filters and pagination.
+  // Each row expands to per-copy job status; partial-failure runs get a
+  // Retry-failed button; in-flight runs get a Cancel button.
+  pipelineRuns: {
+    title: 'Pipeline Runs',
+    loading: 'Loading…',
+    empty: 'No pipeline runs yet.',
+    filter: {
+      pipeline: 'Pipeline',
+      status: 'Status',
+      all: 'All',
+    },
+    copies: '{{n}} copies',
+    failedCount: '{{n}} failed',
+    copyN: 'Copy {{n}}',
+    retryFailed: 'Retry failed',
+    retryOf: 'retry of #{{n}}',
+    pagination: '{{start}}–{{end}} of {{total}}',
+    toast: {
+      cancelled: 'Run cancelled',
+      cancelFailed: 'Cancel failed',
+      retryStarted: 'Retry started',
+      retryFailed: 'Retry failed',
+    },
+    jobStatus: {
+      pending: 'pending',
+      awaiting_printer: 'awaiting printer',
+      queued: 'queued',
+      printing: 'printing',
+      completed: 'completed',
+      failed: 'failed',
+      cancelled: 'cancelled',
+    },
+  },
+
   // Queue page
   // Queue page
   queue: {
   queue: {
     title: 'Print Queue',
     title: 'Print Queue',
@@ -2570,6 +2609,15 @@ export default {
       migrationErrorWarning: '{{count}} legacy row(s) failed to re-encrypt at startup. Check server logs and restart Bambuddy to retry.',
       migrationErrorWarning: '{{count}} legacy row(s) failed to re-encrypt at startup. Check server logs and restart Bambuddy to retry.',
     },
     },
 
 
+    // Slicer Pipeline limits (#1425 PR C). Admin-tunable cap that constrains
+    // the copies input in the Run-with-pipeline modal. Lives on the Workflow
+    // tab's Queue & Dispatch sub-tab.
+    pipelineLimits: {
+      title: 'Slicer Pipeline limits',
+      maxCopiesLabel: 'Max copies per run',
+      maxCopiesDesc: 'Upper bound on the copies operators can request when running a pipeline. Server-side hard cap is 1000.',
+    },
+
     // Slicer Pipelines (#1425): list/edit/delete preset bundles users saved
     // Slicer Pipelines (#1425): list/edit/delete preset bundles users saved
     // from the Slice dialog. Lives in Settings → Workflow → Pipelines sub-tab.
     // from the Slice dialog. Lives in Settings → Workflow → Pipelines sub-tab.
     pipelines: {
     pipelines: {
@@ -2588,6 +2636,17 @@ export default {
         description: 'Description',
         description: 'Description',
         targetPrinter: 'Target printer',
         targetPrinter: 'Target printer',
         noTarget: '— No target —',
         noTarget: '— No target —',
+        // PR C
+        targetKind: 'Target type',
+        targetKindSpecific: 'Specific printer',
+        targetKindClass: 'Printer class',
+        targetModelClass: 'Printer model',
+        fanoutStrategy: 'Fanout strategy',
+        fanout: {
+          max_parallel: 'Max parallel — distribute across any idle matching printer',
+          round_robin: 'Round robin — cycle through eligible printers',
+          fill_one_first: 'Fill one first — pin all copies to one printer',
+        },
       },
       },
       action: {
       action: {
         save: 'Save',
         save: 'Save',
@@ -2620,6 +2679,7 @@ export default {
           in_progress: 'printing',
           in_progress: 'printing',
           completed: 'completed',
           completed: 'completed',
           failed: 'failed',
           failed: 'failed',
+          partial_failure: 'partial failure',
           cancelled: 'cancelled',
           cancelled: 'cancelled',
         },
         },
       },
       },
@@ -3871,6 +3931,10 @@ export default {
       empty: 'No pipelines saved yet. Open the Slice dialog and click "Save as pipeline" to create one.',
       empty: 'No pipelines saved yet. Open the Slice dialog and click "Save as pipeline" to create one.',
       noTarget: 'No target printer set',
       noTarget: 'No target printer set',
       noTargetMessage: 'This pipeline has no target printer set. Open it in Settings to pick one.',
       noTargetMessage: 'This pipeline has no target printer set. Open it in Settings to pick one.',
+      // PR C — copies input + class-targeted pipelines.
+      copies: 'Copies',
+      copiesHint: 'max {{n}}',
+      classTarget: 'Any {{model}}',
       toast: {
       toast: {
         started: 'Pipeline run started',
         started: 'Pipeline run started',
         failed: 'Could not start run',
         failed: 'Could not start run',
@@ -3884,6 +3948,9 @@ export default {
         filamentColor: 'Filament slot {{slot}}: colour differs (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',
         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.',
         filamentUnverified: 'Filament slot {{slot}} comes from a cloud / standard preset and could not be statically verified.',
+        // PR C — class targeting
+        noClassMatches: 'No printers in this install match the pipeline\'s target model class ({{expected}}).',
+        classNotSet: 'Pipeline target is set to a printer class but no model was chosen.',
       },
       },
     },
     },
   },
   },

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

@@ -4,6 +4,7 @@ export default {
     printers: 'Impresoras',
     printers: 'Impresoras',
     archives: 'Archivos',
     archives: 'Archivos',
     queue: 'Cola de impresión',
     queue: 'Cola de impresión',
+    pipelineRuns: 'Ejecuciones de pipeline',
     stats: 'Estadísticas',
     stats: 'Estadísticas',
     profiles: 'Perfiles',
     profiles: 'Perfiles',
     maintenance: 'Mantenimiento',
     maintenance: 'Mantenimiento',
@@ -101,6 +102,8 @@ export default {
     now: 'Ahora',
     now: 'Ahora',
     collapse: 'Contraer',
     collapse: 'Contraer',
     expand: 'Expandir',
     expand: 'Expandir',
+    previous: 'Anterior',
+    next: 'Siguiente',
     viewArchive: 'Ver archivo',
     viewArchive: 'Ver archivo',
     viewInFileManager: 'Ver en el gestor de archivos',
     viewInFileManager: 'Ver en el gestor de archivos',
     addedBy: 'Añadido por {{username}}',
     addedBy: 'Añadido por {{username}}',
@@ -1044,6 +1047,39 @@ export default {
     dismiss: 'Cerrar',
     dismiss: 'Cerrar',
   },
   },
 
 
+  // Pipeline Runs dashboard (#1425 PR C).
+  pipelineRuns: {
+    title: 'Ejecuciones de pipeline',
+    loading: 'Cargando…',
+    empty: 'Aún no hay ejecuciones de pipeline.',
+    filter: {
+      pipeline: 'Pipeline',
+      status: 'Estado',
+      all: 'Todas',
+    },
+    copies: '{{n}} copias',
+    failedCount: '{{n}} fallidas',
+    copyN: 'Copia {{n}}',
+    retryFailed: 'Reintentar fallidas',
+    retryOf: 'reintento de #{{n}}',
+    pagination: '{{start}}–{{end}} de {{total}}',
+    toast: {
+      cancelled: 'Ejecución cancelada',
+      cancelFailed: 'Cancelación fallida',
+      retryStarted: 'Reintento iniciado',
+      retryFailed: 'Reintento fallido',
+    },
+    jobStatus: {
+      pending: 'pendiente',
+      awaiting_printer: 'esperando impresora',
+      queued: 'en cola',
+      printing: 'imprimiendo',
+      completed: 'completada',
+      failed: 'fallida',
+      cancelled: 'cancelada',
+    },
+  },
+
   // Queue page
   // Queue page
   queue: {
   queue: {
     filamentShort: {
     filamentShort: {
@@ -2557,6 +2593,12 @@ export default {
     },
     },
 
 
 
 
+    pipelineLimits: {
+      title: 'Límites de pipelines del cortador',
+      maxCopiesLabel: 'Copias máximas por ejecución',
+      maxCopiesDesc: 'Límite superior de copias que los operadores pueden solicitar al ejecutar una pipeline. El límite máximo del servidor es 1000.',
+    },
+
     // Slicer Pipelines (#1425): list/edit/delete preset bundles users saved
     // Slicer Pipelines (#1425): list/edit/delete preset bundles users saved
     // from the Slice dialog. Lives in Settings → Workflow → Pipelines sub-tab.
     // from the Slice dialog. Lives in Settings → Workflow → Pipelines sub-tab.
     pipelines: {
     pipelines: {
@@ -2575,6 +2617,16 @@ export default {
         description: 'Descripción',
         description: 'Descripción',
         targetPrinter: 'Impresora de destino',
         targetPrinter: 'Impresora de destino',
         noTarget: '— Sin destino —',
         noTarget: '— Sin destino —',
+        targetKind: 'Tipo de destino',
+        targetKindSpecific: 'Impresora específica',
+        targetKindClass: 'Clase de impresora',
+        targetModelClass: 'Modelo de impresora',
+        fanoutStrategy: 'Estrategia de distribución',
+        fanout: {
+          max_parallel: 'Máximo paralelo — distribuir entre cualquier impresora libre coincidente',
+          round_robin: 'Round robin — alternar entre impresoras elegibles',
+          fill_one_first: 'Llenar una primero — fijar todas las copias a una impresora',
+        },
       },
       },
       action: {
       action: {
         save: 'Guardar',
         save: 'Guardar',
@@ -2606,6 +2658,7 @@ export default {
           in_progress: 'imprimiendo',
           in_progress: 'imprimiendo',
           completed: 'completada',
           completed: 'completada',
           failed: 'fallida',
           failed: 'fallida',
+          partial_failure: 'fallo parcial',
           cancelled: 'cancelada',
           cancelled: 'cancelada',
         },
         },
       },
       },
@@ -3854,6 +3907,9 @@ export default {
       empty: 'Aún no hay pipelines guardadas. Abre el diálogo Cortar y haz clic en "Guardar como pipeline" para crear una.',
       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',
       noTarget: 'Sin impresora de destino',
       noTargetMessage: 'Esta pipeline no tiene impresora de destino. Ábrela en Ajustes para elegir una.',
       noTargetMessage: 'Esta pipeline no tiene impresora de destino. Ábrela en Ajustes para elegir una.',
+      copies: 'Copias',
+      copiesHint: 'máx {{n}}',
+      classTarget: 'Cualquier {{model}}',
       toast: {
       toast: {
         started: 'Ejecución de pipeline iniciada',
         started: 'Ejecución de pipeline iniciada',
         failed: 'No se pudo iniciar la ejecución',
         failed: 'No se pudo iniciar la ejecución',
@@ -3867,6 +3923,8 @@ export default {
         filamentColor: 'Slot de filamento {{slot}}: color distinto (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',
         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.',
         filamentUnverified: 'Slot de filamento {{slot}} proviene de un preset de nube / estándar y no se pudo verificar estáticamente.',
+        noClassMatches: 'Ninguna impresora en esta instalación coincide con la clase de modelo objetivo de la pipeline ({{expected}}).',
+        classNotSet: 'El destino de la pipeline es una clase de impresora pero no se eligió ningún modelo.',
       },
       },
     },
     },
   },
   },

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

@@ -4,6 +4,7 @@ export default {
     printers: 'Imprimantes',
     printers: 'Imprimantes',
     archives: 'Archives',
     archives: 'Archives',
     queue: 'File d\'attente d\'impression',
     queue: 'File d\'attente d\'impression',
+    pipelineRuns: 'Exécutions de pipeline',
     stats: 'Statistiques',
     stats: 'Statistiques',
     profiles: 'Profils',
     profiles: 'Profils',
     maintenance: 'Maintenance',
     maintenance: 'Maintenance',
@@ -101,6 +102,8 @@ export default {
     now: 'Maintenant',
     now: 'Maintenant',
     collapse: 'Réduire',
     collapse: 'Réduire',
     expand: 'Développer',
     expand: 'Développer',
+    previous: 'Précédent',
+    next: 'Suivant',
     viewArchive: 'Voir l\'archive',
     viewArchive: 'Voir l\'archive',
     viewInFileManager: 'Voir dans le gestionnaire de fichiers',
     viewInFileManager: 'Voir dans le gestionnaire de fichiers',
     addedBy: 'Ajouté par {{username}}',
     addedBy: 'Ajouté par {{username}}',
@@ -1044,6 +1047,39 @@ export default {
     dismiss: 'Fermer',
     dismiss: 'Fermer',
   },
   },
 
 
+  // Pipeline Runs dashboard (#1425 PR C).
+  pipelineRuns: {
+    title: 'Exécutions de pipeline',
+    loading: 'Chargement…',
+    empty: 'Aucune exécution de pipeline pour le moment.',
+    filter: {
+      pipeline: 'Pipeline',
+      status: 'Statut',
+      all: 'Toutes',
+    },
+    copies: '{{n}} copies',
+    failedCount: '{{n}} échouées',
+    copyN: 'Copie {{n}}',
+    retryFailed: 'Réessayer les échouées',
+    retryOf: 'réessai de #{{n}}',
+    pagination: '{{start}}–{{end}} sur {{total}}',
+    toast: {
+      cancelled: 'Exécution annulée',
+      cancelFailed: 'Annulation échouée',
+      retryStarted: 'Réessai démarré',
+      retryFailed: 'Réessai échoué',
+    },
+    jobStatus: {
+      pending: 'en attente',
+      awaiting_printer: 'attente imprimante',
+      queued: 'en file',
+      printing: 'impression',
+      completed: 'terminée',
+      failed: 'échouée',
+      cancelled: 'annulée',
+    },
+  },
+
   // Queue page
   // Queue page
   queue: {
   queue: {
     filamentShort: {
     filamentShort: {
@@ -2543,6 +2579,12 @@ export default {
       commandError: 'Échec de l\'envoi de la commande',
       commandError: 'Échec de l\'envoi de la commande',
     },
     },
 
 
+    pipelineLimits: {
+      title: 'Limites des pipelines du trancheur',
+      maxCopiesLabel: 'Copies maximales par exécution',
+      maxCopiesDesc: 'Limite supérieure du nombre de copies que les opérateurs peuvent demander lors de l\'exécution d\'un pipeline. La limite stricte côté serveur est 1000.',
+    },
+
     // Slicer Pipelines (#1425): list/edit/delete preset bundles users saved
     // Slicer Pipelines (#1425): list/edit/delete preset bundles users saved
     // from the Slice dialog. Lives in Settings → Workflow → Pipelines sub-tab.
     // from the Slice dialog. Lives in Settings → Workflow → Pipelines sub-tab.
     pipelines: {
     pipelines: {
@@ -2561,6 +2603,16 @@ export default {
         description: 'Description',
         description: 'Description',
         targetPrinter: 'Imprimante cible',
         targetPrinter: 'Imprimante cible',
         noTarget: '— Aucune cible —',
         noTarget: '— Aucune cible —',
+        targetKind: 'Type de cible',
+        targetKindSpecific: 'Imprimante spécifique',
+        targetKindClass: 'Classe d\'imprimante',
+        targetModelClass: 'Modèle d\'imprimante',
+        fanoutStrategy: 'Stratégie de distribution',
+        fanout: {
+          max_parallel: 'Max parallèle — distribuer sur toute imprimante libre correspondante',
+          round_robin: 'Round robin — alterner entre imprimantes éligibles',
+          fill_one_first: 'Remplir une d\'abord — épingler toutes les copies à une imprimante',
+        },
       },
       },
       action: {
       action: {
         save: 'Enregistrer',
         save: 'Enregistrer',
@@ -2592,6 +2644,7 @@ export default {
           in_progress: 'impression',
           in_progress: 'impression',
           completed: 'terminé',
           completed: 'terminé',
           failed: 'échec',
           failed: 'échec',
+          partial_failure: 'échec partiel',
           cancelled: 'annulé',
           cancelled: 'annulé',
         },
         },
       },
       },
@@ -3840,6 +3893,9 @@ export default {
       empty: 'Aucun pipeline enregistré pour le moment. Ouvrez le dialogue Trancher et cliquez sur « Enregistrer comme pipeline » pour en créer un.',
       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',
       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.',
       noTargetMessage: 'Ce pipeline n\'a pas d\'imprimante cible. Ouvrez-le dans les Paramètres pour en choisir une.',
+      copies: 'Copies',
+      copiesHint: 'max {{n}}',
+      classTarget: 'N\'importe quel {{model}}',
       toast: {
       toast: {
         started: 'Exécution de pipeline démarrée',
         started: 'Exécution de pipeline démarrée',
         failed: 'Impossible de démarrer l\'exécution',
         failed: 'Impossible de démarrer l\'exécution',
@@ -3853,6 +3909,8 @@ export default {
         filamentColor: 'Emplacement filament {{slot}} : couleur différente (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',
         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.',
         filamentUnverified: 'L\'emplacement filament {{slot}} provient d\'un préréglage cloud / standard et n\'a pas pu être vérifié statiquement.',
+        noClassMatches: 'Aucune imprimante de cette installation ne correspond à la classe de modèle cible du pipeline ({{expected}}).',
+        classNotSet: 'La cible du pipeline est une classe d\'imprimante mais aucun modèle n\'a été choisi.',
       },
       },
     },
     },
   },
   },

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

@@ -4,6 +4,7 @@ export default {
     printers: 'Stampanti',
     printers: 'Stampanti',
     archives: 'Archivi',
     archives: 'Archivi',
     queue: 'Coda di stampa',
     queue: 'Coda di stampa',
+    pipelineRuns: 'Esecuzioni pipeline',
     stats: 'Statistiche',
     stats: 'Statistiche',
     profiles: 'Profili',
     profiles: 'Profili',
     maintenance: 'Manutenzione',
     maintenance: 'Manutenzione',
@@ -101,6 +102,8 @@ export default {
     now: 'Ora',
     now: 'Ora',
     collapse: 'Comprimi',
     collapse: 'Comprimi',
     expand: 'Espandi',
     expand: 'Espandi',
+    previous: 'Precedente',
+    next: 'Successivo',
     viewArchive: 'Vedi archivio',
     viewArchive: 'Vedi archivio',
     viewInFileManager: 'Vedi nel Gestore file',
     viewInFileManager: 'Vedi nel Gestore file',
     addedBy: 'Aggiunto da {{username}}',
     addedBy: 'Aggiunto da {{username}}',
@@ -1044,6 +1047,39 @@ export default {
     dismiss: 'Chiudi',
     dismiss: 'Chiudi',
   },
   },
 
 
+  // Pipeline Runs dashboard (#1425 PR C).
+  pipelineRuns: {
+    title: 'Esecuzioni pipeline',
+    loading: 'Caricamento…',
+    empty: 'Nessuna esecuzione di pipeline ancora.',
+    filter: {
+      pipeline: 'Pipeline',
+      status: 'Stato',
+      all: 'Tutte',
+    },
+    copies: '{{n}} copie',
+    failedCount: '{{n}} fallite',
+    copyN: 'Copia {{n}}',
+    retryFailed: 'Riprova fallite',
+    retryOf: 'ritentativo di #{{n}}',
+    pagination: '{{start}}–{{end}} di {{total}}',
+    toast: {
+      cancelled: 'Esecuzione annullata',
+      cancelFailed: 'Annullamento fallito',
+      retryStarted: 'Ritentativo avviato',
+      retryFailed: 'Ritentativo fallito',
+    },
+    jobStatus: {
+      pending: 'in attesa',
+      awaiting_printer: 'in attesa stampante',
+      queued: 'in coda',
+      printing: 'stampa',
+      completed: 'completata',
+      failed: 'fallita',
+      cancelled: 'annullata',
+    },
+  },
+
   // Queue page
   // Queue page
   queue: {
   queue: {
     filamentShort: {
     filamentShort: {
@@ -2542,6 +2578,12 @@ export default {
       commandError: 'Invio comando non riuscito',
       commandError: 'Invio comando non riuscito',
     },
     },
 
 
+    pipelineLimits: {
+      title: 'Limiti pipeline dello slicer',
+      maxCopiesLabel: 'Copie massime per esecuzione',
+      maxCopiesDesc: 'Limite superiore alle copie che gli operatori possono richiedere durante l\'esecuzione di una pipeline. Il limite massimo lato server è 1000.',
+    },
+
     // Slicer Pipelines (#1425): list/edit/delete preset bundles users saved
     // Slicer Pipelines (#1425): list/edit/delete preset bundles users saved
     // from the Slice dialog. Lives in Settings → Workflow → Pipelines sub-tab.
     // from the Slice dialog. Lives in Settings → Workflow → Pipelines sub-tab.
     pipelines: {
     pipelines: {
@@ -2560,6 +2602,16 @@ export default {
         description: 'Descrizione',
         description: 'Descrizione',
         targetPrinter: 'Stampante di destinazione',
         targetPrinter: 'Stampante di destinazione',
         noTarget: '— Nessuna destinazione —',
         noTarget: '— Nessuna destinazione —',
+        targetKind: 'Tipo destinazione',
+        targetKindSpecific: 'Stampante specifica',
+        targetKindClass: 'Classe stampante',
+        targetModelClass: 'Modello stampante',
+        fanoutStrategy: 'Strategia di distribuzione',
+        fanout: {
+          max_parallel: 'Max parallelo — distribuisci su qualsiasi stampante libera corrispondente',
+          round_robin: 'Round robin — alterna tra stampanti idonee',
+          fill_one_first: 'Riempi una prima — assegna tutte le copie a una stampante',
+        },
       },
       },
       action: {
       action: {
         save: 'Salva',
         save: 'Salva',
@@ -2591,6 +2643,7 @@ export default {
           in_progress: 'stampa',
           in_progress: 'stampa',
           completed: 'completata',
           completed: 'completata',
           failed: 'fallita',
           failed: 'fallita',
+          partial_failure: 'fallimento parziale',
           cancelled: 'annullata',
           cancelled: 'annullata',
         },
         },
       },
       },
@@ -3839,6 +3892,9 @@ export default {
       empty: 'Nessuna pipeline salvata. Apri il dialogo Slice e fai clic su "Salva come pipeline" per crearne una.',
       empty: 'Nessuna pipeline salvata. Apri il dialogo Slice e fai clic su "Salva come pipeline" per crearne una.',
       noTarget: 'Nessuna stampante di destinazione',
       noTarget: 'Nessuna stampante di destinazione',
       noTargetMessage: 'Questa pipeline non ha una stampante di destinazione. Aprila nelle Impostazioni per sceglierne una.',
       noTargetMessage: 'Questa pipeline non ha una stampante di destinazione. Aprila nelle Impostazioni per sceglierne una.',
+      copies: 'Copie',
+      copiesHint: 'max {{n}}',
+      classTarget: 'Qualsiasi {{model}}',
       toast: {
       toast: {
         started: 'Esecuzione della pipeline avviata',
         started: 'Esecuzione della pipeline avviata',
         failed: 'Impossibile avviare l\'esecuzione',
         failed: 'Impossibile avviare l\'esecuzione',
@@ -3852,6 +3908,8 @@ export default {
         filamentColor: 'Slot filamento {{slot}}: colore differente (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',
         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.',
         filamentUnverified: 'Lo slot filamento {{slot}} proviene da un preset cloud / standard e non può essere verificato staticamente.',
+        noClassMatches: 'Nessuna stampante in questa installazione corrisponde alla classe di modello target della pipeline ({{expected}}).',
+        classNotSet: 'La destinazione della pipeline è una classe di stampante ma nessun modello è stato scelto.',
       },
       },
     },
     },
   },
   },

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

@@ -4,6 +4,7 @@ export default {
     printers: 'プリンター',
     printers: 'プリンター',
     archives: 'アーカイブ',
     archives: 'アーカイブ',
     queue: '印刷キュー',
     queue: '印刷キュー',
+    pipelineRuns: 'パイプライン実行',
     stats: '統計',
     stats: '統計',
     profiles: 'プロファイル',
     profiles: 'プロファイル',
     maintenance: 'メンテナンス',
     maintenance: 'メンテナンス',
@@ -101,6 +102,8 @@ export default {
     now: '今すぐ',
     now: '今すぐ',
     collapse: '折りたたむ',
     collapse: '折りたたむ',
     expand: '展開',
     expand: '展開',
+    previous: '前へ',
+    next: '次へ',
     viewArchive: 'アーカイブを表示',
     viewArchive: 'アーカイブを表示',
     viewInFileManager: 'ファイルマネージャーで表示',
     viewInFileManager: 'ファイルマネージャーで表示',
     addedBy: '{{username}}が追加',
     addedBy: '{{username}}が追加',
@@ -1043,6 +1046,39 @@ export default {
     dismiss: '閉じる',
     dismiss: '閉じる',
   },
   },
 
 
+  // Pipeline Runs dashboard (#1425 PR C).
+  pipelineRuns: {
+    title: 'パイプライン実行',
+    loading: '読み込み中…',
+    empty: 'パイプラインの実行はまだありません。',
+    filter: {
+      pipeline: 'パイプライン',
+      status: 'ステータス',
+      all: 'すべて',
+    },
+    copies: '{{n}} 部',
+    failedCount: '{{n}} 失敗',
+    copyN: 'コピー {{n}}',
+    retryFailed: '失敗を再試行',
+    retryOf: '#{{n}} の再試行',
+    pagination: '{{total}} 件中 {{start}}–{{end}}',
+    toast: {
+      cancelled: '実行をキャンセルしました',
+      cancelFailed: 'キャンセルに失敗しました',
+      retryStarted: '再試行を開始しました',
+      retryFailed: '再試行に失敗しました',
+    },
+    jobStatus: {
+      pending: '保留中',
+      awaiting_printer: 'プリンター待機中',
+      queued: '待機中',
+      printing: '印刷中',
+      completed: '完了',
+      failed: '失敗',
+      cancelled: 'キャンセル',
+    },
+  },
+
   // Queue page
   // Queue page
   queue: {
   queue: {
     filamentShort: {
     filamentShort: {
@@ -2554,6 +2590,12 @@ export default {
     },
     },
 
 
 
 
+    pipelineLimits: {
+      title: 'スライサーパイプラインの上限',
+      maxCopiesLabel: '実行あたりの最大コピー数',
+      maxCopiesDesc: 'パイプライン実行時にオペレーターが要求できるコピー数の上限。サーバー側の上限は 1000 です。',
+    },
+
     // Slicer Pipelines (#1425): list/edit/delete preset bundles users saved
     // Slicer Pipelines (#1425): list/edit/delete preset bundles users saved
     // from the Slice dialog. Lives in Settings → Workflow → Pipelines sub-tab.
     // from the Slice dialog. Lives in Settings → Workflow → Pipelines sub-tab.
     pipelines: {
     pipelines: {
@@ -2572,6 +2614,16 @@ export default {
         description: '説明',
         description: '説明',
         targetPrinter: '対象プリンター',
         targetPrinter: '対象プリンター',
         noTarget: '— 対象なし —',
         noTarget: '— 対象なし —',
+        targetKind: '対象種別',
+        targetKindSpecific: '特定のプリンター',
+        targetKindClass: 'プリンタークラス',
+        targetModelClass: 'プリンターモデル',
+        fanoutStrategy: '分散戦略',
+        fanout: {
+          max_parallel: '最大並列 — 一致する空きプリンターに分散',
+          round_robin: 'ラウンドロビン — 適格なプリンター間で循環',
+          fill_one_first: '1台ずつ埋める — すべてのコピーを1台に固定',
+        },
       },
       },
       action: {
       action: {
         save: '保存',
         save: '保存',
@@ -2603,6 +2655,7 @@ export default {
           in_progress: '印刷中',
           in_progress: '印刷中',
           completed: '完了',
           completed: '完了',
           failed: '失敗',
           failed: '失敗',
+          partial_failure: '部分的失敗',
           cancelled: 'キャンセル',
           cancelled: 'キャンセル',
         },
         },
       },
       },
@@ -3851,6 +3904,9 @@ export default {
       empty: '保存されたパイプラインはまだありません。スライスダイアログを開き「パイプラインとして保存」をクリックして作成してください。',
       empty: '保存されたパイプラインはまだありません。スライスダイアログを開き「パイプラインとして保存」をクリックして作成してください。',
       noTarget: '対象プリンターが未設定',
       noTarget: '対象プリンターが未設定',
       noTargetMessage: 'このパイプラインには対象プリンターが設定されていません。設定で開いて選択してください。',
       noTargetMessage: 'このパイプラインには対象プリンターが設定されていません。設定で開いて選択してください。',
+      copies: 'コピー数',
+      copiesHint: '最大 {{n}}',
+      classTarget: '任意の {{model}}',
       toast: {
       toast: {
         started: 'パイプラインの実行を開始しました',
         started: 'パイプラインの実行を開始しました',
         failed: '実行を開始できませんでした',
         failed: '実行を開始できませんでした',
@@ -3864,6 +3920,8 @@ export default {
         filamentColor: 'フィラメントスロット {{slot}}: 色が異なります(期待 {{expected}}、AMS は {{actual}})',
         filamentColor: 'フィラメントスロット {{slot}}: 色が異なります(期待 {{expected}}、AMS は {{actual}})',
         amsSlotMissing: 'AMS スロット {{slot}} はこのプリンターで利用できません',
         amsSlotMissing: 'AMS スロット {{slot}} はこのプリンターで利用できません',
         filamentUnverified: 'フィラメントスロット {{slot}} はクラウド/標準プリセットからのため、静的に検証できませんでした。',
         filamentUnverified: 'フィラメントスロット {{slot}} はクラウド/標準プリセットからのため、静的に検証できませんでした。',
+        noClassMatches: 'このインストール内に、パイプラインの対象モデルクラス({{expected}})と一致するプリンターがありません。',
+        classNotSet: 'パイプラインの対象がプリンタークラスに設定されていますが、モデルが選択されていません。',
       },
       },
     },
     },
   },
   },

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

@@ -3,6 +3,7 @@ export default {
     printers: '프린터',
     printers: '프린터',
     archives: '아카이브',
     archives: '아카이브',
     queue: '대기열',
     queue: '대기열',
+    pipelineRuns: '파이프라인 실행',
     stats: '통계',
     stats: '통계',
     profiles: '프로필',
     profiles: '프로필',
     maintenance: '유지보수',
     maintenance: '유지보수',
@@ -98,6 +99,8 @@ export default {
     now: '지금',
     now: '지금',
     collapse: '접기',
     collapse: '접기',
     expand: '펼치기',
     expand: '펼치기',
+    previous: '이전',
+    next: '다음',
     viewArchive: '아카이브 보기',
     viewArchive: '아카이브 보기',
     viewInFileManager: '파일 관리자에서 보기',
     viewInFileManager: '파일 관리자에서 보기',
     addedBy: '{{username}}님이 추가함',
     addedBy: '{{username}}님이 추가함',
@@ -998,6 +1001,39 @@ export default {
     },
     },
     dismiss: '닫기',
     dismiss: '닫기',
   },
   },
+  // Pipeline Runs dashboard (#1425 PR C).
+  pipelineRuns: {
+    title: '파이프라인 실행',
+    loading: '불러오는 중…',
+    empty: '아직 파이프라인 실행이 없습니다.',
+    filter: {
+      pipeline: '파이프라인',
+      status: '상태',
+      all: '전체',
+    },
+    copies: '사본 {{n}}',
+    failedCount: '실패 {{n}}',
+    copyN: '사본 {{n}}',
+    retryFailed: '실패 재시도',
+    retryOf: '#{{n}}의 재시도',
+    pagination: '{{total}} 중 {{start}}–{{end}}',
+    toast: {
+      cancelled: '실행 취소됨',
+      cancelFailed: '취소 실패',
+      retryStarted: '재시도 시작됨',
+      retryFailed: '재시도 실패',
+    },
+    jobStatus: {
+      pending: '대기 중',
+      awaiting_printer: '프린터 대기 중',
+      queued: '대기 중',
+      printing: '인쇄 중',
+      completed: '완료됨',
+      failed: '실패',
+      cancelled: '취소됨',
+    },
+  },
+
   queue: {
   queue: {
     title: '인쇄 대기열',
     title: '인쇄 대기열',
     subtitle: '인쇄 작업을 예약하고 관리하세요',
     subtitle: '인쇄 작업을 예약하고 관리하세요',
@@ -2410,6 +2446,12 @@ export default {
     energyCostBadge: '에너지',
     energyCostBadge: '에너지',
     passwordRequirements: '최소 8자, 대문자, 소문자, 숫자, 특수문자 각 1개 이상 포함',
     passwordRequirements: '최소 8자, 대문자, 소문자, 숫자, 특수문자 각 1개 이상 포함',
 
 
+    pipelineLimits: {
+      title: '슬라이서 파이프라인 한도',
+      maxCopiesLabel: '실행당 최대 사본 수',
+      maxCopiesDesc: '운영자가 파이프라인 실행 시 요청할 수 있는 사본 수의 상한. 서버 측 절대 상한은 1000입니다.',
+    },
+
     // Slicer Pipelines (#1425): list/edit/delete preset bundles users saved
     // Slicer Pipelines (#1425): list/edit/delete preset bundles users saved
     // from the Slice dialog. Lives in Settings → Workflow → Pipelines sub-tab.
     // from the Slice dialog. Lives in Settings → Workflow → Pipelines sub-tab.
     pipelines: {
     pipelines: {
@@ -2428,6 +2470,16 @@ export default {
         description: '설명',
         description: '설명',
         targetPrinter: '대상 프린터',
         targetPrinter: '대상 프린터',
         noTarget: '— 대상 없음 —',
         noTarget: '— 대상 없음 —',
+        targetKind: '대상 유형',
+        targetKindSpecific: '특정 프린터',
+        targetKindClass: '프린터 클래스',
+        targetModelClass: '프린터 모델',
+        fanoutStrategy: '분산 전략',
+        fanout: {
+          max_parallel: '최대 병렬 — 일치하는 유휴 프린터에 분산',
+          round_robin: '라운드 로빈 — 적격 프린터를 순환',
+          fill_one_first: '하나 먼저 채우기 — 모든 사본을 한 프린터에 고정',
+        },
       },
       },
       action: {
       action: {
         save: '저장',
         save: '저장',
@@ -2459,6 +2511,7 @@ export default {
           in_progress: '인쇄 중',
           in_progress: '인쇄 중',
           completed: '완료됨',
           completed: '완료됨',
           failed: '실패',
           failed: '실패',
+          partial_failure: '부분 실패',
           cancelled: '취소됨',
           cancelled: '취소됨',
         },
         },
       },
       },
@@ -3646,6 +3699,9 @@ export default {
       empty: '저장된 파이프라인이 없습니다. 슬라이스 대화상자를 열고 "파이프라인으로 저장"을 클릭하여 생성하세요.',
       empty: '저장된 파이프라인이 없습니다. 슬라이스 대화상자를 열고 "파이프라인으로 저장"을 클릭하여 생성하세요.',
       noTarget: '대상 프린터가 설정되지 않음',
       noTarget: '대상 프린터가 설정되지 않음',
       noTargetMessage: '이 파이프라인에는 대상 프린터가 없습니다. 설정에서 열어 선택하세요.',
       noTargetMessage: '이 파이프라인에는 대상 프린터가 없습니다. 설정에서 열어 선택하세요.',
+      copies: '사본 수',
+      copiesHint: '최대 {{n}}',
+      classTarget: '임의의 {{model}}',
       toast: {
       toast: {
         started: '파이프라인 실행 시작됨',
         started: '파이프라인 실행 시작됨',
         failed: '실행을 시작할 수 없습니다',
         failed: '실행을 시작할 수 없습니다',
@@ -3659,6 +3715,8 @@ export default {
         filamentColor: '필라멘트 슬롯 {{slot}}: 색상이 다릅니다 (예상 {{expected}}, AMS {{actual}})',
         filamentColor: '필라멘트 슬롯 {{slot}}: 색상이 다릅니다 (예상 {{expected}}, AMS {{actual}})',
         amsSlotMissing: 'AMS 슬롯 {{slot}}이(가) 이 프린터에서 사용 불가',
         amsSlotMissing: 'AMS 슬롯 {{slot}}이(가) 이 프린터에서 사용 불가',
         filamentUnverified: '필라멘트 슬롯 {{slot}}은(는) 클라우드/표준 프리셋이며 정적으로 검증할 수 없습니다.',
         filamentUnverified: '필라멘트 슬롯 {{slot}}은(는) 클라우드/표준 프리셋이며 정적으로 검증할 수 없습니다.',
+        noClassMatches: '이 설치에서 파이프라인의 대상 모델 클래스({{expected}})와 일치하는 프린터가 없습니다.',
+        classNotSet: '파이프라인 대상이 프린터 클래스로 설정되었지만 모델이 선택되지 않았습니다.',
       },
       },
     },
     },
   },
   },

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

@@ -4,6 +4,7 @@ export default {
     printers: 'Impressoras',
     printers: 'Impressoras',
     archives: 'Arquivos',
     archives: 'Arquivos',
     queue: 'Fila de impressão',
     queue: 'Fila de impressão',
+    pipelineRuns: 'Execuções de pipeline',
     stats: 'Estatísticas',
     stats: 'Estatísticas',
     profiles: 'Perfis',
     profiles: 'Perfis',
     maintenance: 'Manutenção',
     maintenance: 'Manutenção',
@@ -101,6 +102,8 @@ export default {
     now: 'Agora',
     now: 'Agora',
     collapse: 'Recolher',
     collapse: 'Recolher',
     expand: 'Expandir',
     expand: 'Expandir',
+    previous: 'Anterior',
+    next: 'Próximo',
     viewArchive: 'Ver arquivo',
     viewArchive: 'Ver arquivo',
     viewInFileManager: 'Ver no Gerenciador de Arquivos',
     viewInFileManager: 'Ver no Gerenciador de Arquivos',
     addedBy: 'Adicionado por {{username}}',
     addedBy: 'Adicionado por {{username}}',
@@ -1044,6 +1047,39 @@ export default {
     dismiss: 'Fechar',
     dismiss: 'Fechar',
   },
   },
 
 
+  // Pipeline Runs dashboard (#1425 PR C).
+  pipelineRuns: {
+    title: 'Execuções de pipeline',
+    loading: 'Carregando…',
+    empty: 'Nenhuma execução de pipeline ainda.',
+    filter: {
+      pipeline: 'Pipeline',
+      status: 'Status',
+      all: 'Todas',
+    },
+    copies: '{{n}} cópias',
+    failedCount: '{{n}} falharam',
+    copyN: 'Cópia {{n}}',
+    retryFailed: 'Repetir falhas',
+    retryOf: 'nova tentativa de #{{n}}',
+    pagination: '{{start}}–{{end}} de {{total}}',
+    toast: {
+      cancelled: 'Execução cancelada',
+      cancelFailed: 'Cancelamento falhou',
+      retryStarted: 'Nova tentativa iniciada',
+      retryFailed: 'Nova tentativa falhou',
+    },
+    jobStatus: {
+      pending: 'pendente',
+      awaiting_printer: 'aguardando impressora',
+      queued: 'na fila',
+      printing: 'imprimindo',
+      completed: 'concluída',
+      failed: 'falhou',
+      cancelled: 'cancelada',
+    },
+  },
+
   // Queue page
   // Queue page
   queue: {
   queue: {
     filamentShort: {
     filamentShort: {
@@ -2542,6 +2578,12 @@ export default {
       commandError: 'Falha ao enviar comando',
       commandError: 'Falha ao enviar comando',
     },
     },
 
 
+    pipelineLimits: {
+      title: 'Limites de pipelines do slicer',
+      maxCopiesLabel: 'Cópias máximas por execução',
+      maxCopiesDesc: 'Limite superior de cópias que operadores podem solicitar ao executar uma pipeline. O limite rígido no lado do servidor é 1000.',
+    },
+
     // Slicer Pipelines (#1425): list/edit/delete preset bundles users saved
     // Slicer Pipelines (#1425): list/edit/delete preset bundles users saved
     // from the Slice dialog. Lives in Settings → Workflow → Pipelines sub-tab.
     // from the Slice dialog. Lives in Settings → Workflow → Pipelines sub-tab.
     pipelines: {
     pipelines: {
@@ -2560,6 +2602,16 @@ export default {
         description: 'Descrição',
         description: 'Descrição',
         targetPrinter: 'Impressora de destino',
         targetPrinter: 'Impressora de destino',
         noTarget: '— Sem destino —',
         noTarget: '— Sem destino —',
+        targetKind: 'Tipo de destino',
+        targetKindSpecific: 'Impressora específica',
+        targetKindClass: 'Classe de impressora',
+        targetModelClass: 'Modelo de impressora',
+        fanoutStrategy: 'Estratégia de distribuição',
+        fanout: {
+          max_parallel: 'Máximo paralelo — distribuir em qualquer impressora ociosa compatível',
+          round_robin: 'Round robin — alternar entre impressoras elegíveis',
+          fill_one_first: 'Encher uma primeiro — fixar todas as cópias em uma impressora',
+        },
       },
       },
       action: {
       action: {
         save: 'Salvar',
         save: 'Salvar',
@@ -2591,6 +2643,7 @@ export default {
           in_progress: 'imprimindo',
           in_progress: 'imprimindo',
           completed: 'concluída',
           completed: 'concluída',
           failed: 'falhou',
           failed: 'falhou',
+          partial_failure: 'falha parcial',
           cancelled: 'cancelada',
           cancelled: 'cancelada',
         },
         },
       },
       },
@@ -3839,6 +3892,9 @@ export default {
       empty: 'Ainda não há pipelines salvas. Abra o diálogo Cortar e clique em "Salvar como pipeline" para criar uma.',
       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',
       noTarget: 'Sem impressora de destino',
       noTargetMessage: 'Esta pipeline não tem impressora de destino. Abra-a em Configurações para escolher uma.',
       noTargetMessage: 'Esta pipeline não tem impressora de destino. Abra-a em Configurações para escolher uma.',
+      copies: 'Cópias',
+      copiesHint: 'máx {{n}}',
+      classTarget: 'Qualquer {{model}}',
       toast: {
       toast: {
         started: 'Execução da pipeline iniciada',
         started: 'Execução da pipeline iniciada',
         failed: 'Não foi possível iniciar a execução',
         failed: 'Não foi possível iniciar a execução',
@@ -3852,6 +3908,8 @@ export default {
         filamentColor: 'Slot de filamento {{slot}}: cor diferente (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',
         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.',
         filamentUnverified: 'Slot de filamento {{slot}} vem de uma predefinição da nuvem / padrão e não pôde ser verificado estaticamente.',
+        noClassMatches: 'Nenhuma impressora nesta instalação corresponde à classe de modelo de destino da pipeline ({{expected}}).',
+        classNotSet: 'O destino da pipeline está configurado como classe de impressora, mas nenhum modelo foi escolhido.',
       },
       },
     },
     },
   },
   },

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

@@ -4,6 +4,7 @@ export default {
     printers: 'Yazıcılar',
     printers: 'Yazıcılar',
     archives: 'Arşivler',
     archives: 'Arşivler',
     queue: 'Baskı Kuyruğu',
     queue: 'Baskı Kuyruğu',
+    pipelineRuns: 'Pipeline çalıştırmaları',
     stats: 'İstatistikler',
     stats: 'İstatistikler',
     profiles: 'Profiller',
     profiles: 'Profiller',
     maintenance: 'Bakım',
     maintenance: 'Bakım',
@@ -101,6 +102,8 @@ export default {
     now: 'Şimdi',
     now: 'Şimdi',
     collapse: 'Daralt',
     collapse: 'Daralt',
     expand: 'Genişlet',
     expand: 'Genişlet',
+    previous: 'Önceki',
+    next: 'Sonraki',
     viewArchive: 'Arşivi gör',
     viewArchive: 'Arşivi gör',
     viewInFileManager: 'Dosya Yöneticisinde gör',
     viewInFileManager: 'Dosya Yöneticisinde gör',
     addedBy: '{{username}} tarafından eklendi',
     addedBy: '{{username}} tarafından eklendi',
@@ -1045,6 +1048,39 @@ export default {
   },
   },
 
 
   // Kuyruk sayfası
   // Kuyruk sayfası
+  // Pipeline Runs dashboard (#1425 PR C).
+  pipelineRuns: {
+    title: 'Pipeline çalıştırmaları',
+    loading: 'Yükleniyor…',
+    empty: 'Henüz pipeline çalıştırması yok.',
+    filter: {
+      pipeline: 'Pipeline',
+      status: 'Durum',
+      all: 'Tümü',
+    },
+    copies: '{{n}} kopya',
+    failedCount: '{{n}} başarısız',
+    copyN: 'Kopya {{n}}',
+    retryFailed: 'Başarısızları tekrar dene',
+    retryOf: '#{{n}} yeniden denemesi',
+    pagination: '{{total}} içinden {{start}}–{{end}}',
+    toast: {
+      cancelled: 'Çalıştırma iptal edildi',
+      cancelFailed: 'İptal başarısız',
+      retryStarted: 'Yeniden deneme başlatıldı',
+      retryFailed: 'Yeniden deneme başarısız',
+    },
+    jobStatus: {
+      pending: 'beklemede',
+      awaiting_printer: 'yazıcı bekleniyor',
+      queued: 'kuyrukta',
+      printing: 'yazdırılıyor',
+      completed: 'tamamlandı',
+      failed: 'başarısız',
+      cancelled: 'iptal edildi',
+    },
+  },
+
   queue: {
   queue: {
     title: 'Baskı Kuyruğu',
     title: 'Baskı Kuyruğu',
     subtitle: 'Baskı işlerinizi zamanlayın ve yönetin',
     subtitle: 'Baskı işlerinizi zamanlayın ve yönetin',
@@ -2558,6 +2594,12 @@ export default {
     },
     },
 
 
 
 
+    pipelineLimits: {
+      title: 'Dilimleyici pipeline limitleri',
+      maxCopiesLabel: 'Çalıştırma başına maksimum kopya',
+      maxCopiesDesc: 'Bir pipeline çalıştırıldığında operatörlerin isteyebileceği kopya sayısı üst sınırı. Sunucu tarafı sert sınır 1000\'dir.',
+    },
+
     // Slicer Pipelines (#1425): list/edit/delete preset bundles users saved
     // Slicer Pipelines (#1425): list/edit/delete preset bundles users saved
     // from the Slice dialog. Lives in Settings → Workflow → Pipelines sub-tab.
     // from the Slice dialog. Lives in Settings → Workflow → Pipelines sub-tab.
     pipelines: {
     pipelines: {
@@ -2576,6 +2618,16 @@ export default {
         description: 'Açıklama',
         description: 'Açıklama',
         targetPrinter: 'Hedef yazıcı',
         targetPrinter: 'Hedef yazıcı',
         noTarget: '— Hedef yok —',
         noTarget: '— Hedef yok —',
+        targetKind: 'Hedef türü',
+        targetKindSpecific: 'Belirli yazıcı',
+        targetKindClass: 'Yazıcı sınıfı',
+        targetModelClass: 'Yazıcı modeli',
+        fanoutStrategy: 'Dağıtım stratejisi',
+        fanout: {
+          max_parallel: 'Maksimum paralel — eşleşen boş yazıcılara dağıt',
+          round_robin: 'Sırayla — uygun yazıcılar arasında döndür',
+          fill_one_first: 'Önce birini doldur — tüm kopyaları tek yazıcıya sabitle',
+        },
       },
       },
       action: {
       action: {
         save: 'Kaydet',
         save: 'Kaydet',
@@ -2607,6 +2659,7 @@ export default {
           in_progress: 'yazdırılıyor',
           in_progress: 'yazdırılıyor',
           completed: 'tamamlandı',
           completed: 'tamamlandı',
           failed: 'başarısız',
           failed: 'başarısız',
+          partial_failure: 'kısmi başarısızlık',
           cancelled: 'iptal edildi',
           cancelled: 'iptal edildi',
         },
         },
       },
       },
@@ -3841,6 +3894,9 @@ export default {
       empty: 'Henüz kayıtlı pipeline yok. Dilimleme diyalogunu açın ve oluşturmak için "Pipeline olarak kaydet"e tıklayın.',
       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ş',
       noTarget: 'Hedef yazıcı belirlenmemiş',
       noTargetMessage: 'Bu pipeline\'ın hedef yazıcısı yok. Birini seçmek için Ayarlar\'da açın.',
       noTargetMessage: 'Bu pipeline\'ın hedef yazıcısı yok. Birini seçmek için Ayarlar\'da açın.',
+      copies: 'Kopyalar',
+      copiesHint: 'maks {{n}}',
+      classTarget: 'Herhangi {{model}}',
       toast: {
       toast: {
         started: 'Pipeline çalıştırması başladı',
         started: 'Pipeline çalıştırması başladı',
         failed: 'Çalıştırma başlatılamadı',
         failed: 'Çalıştırma başlatılamadı',
@@ -3854,6 +3910,8 @@ export default {
         filamentColor: 'Filament yuvası {{slot}}: renk farklı (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',
         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ı.',
         filamentUnverified: 'Filament yuvası {{slot}} bulut / standart ön ayardan geldiği için statik olarak doğrulanamadı.',
+        noClassMatches: 'Bu kurulumda pipeline\'ın hedef model sınıfıyla ({{expected}}) eşleşen yazıcı yok.',
+        classNotSet: 'Pipeline hedefi yazıcı sınıfı olarak ayarlanmış ancak model seçilmemiş.',
       },
       },
     },
     },
   },
   },

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

@@ -4,6 +4,7 @@ export default {
     printers: '打印机',
     printers: '打印机',
     archives: '归档',
     archives: '归档',
     queue: '打印队列',
     queue: '打印队列',
+    pipelineRuns: '流水线运行',
     stats: '统计',
     stats: '统计',
     profiles: '配置文件',
     profiles: '配置文件',
     maintenance: '维护',
     maintenance: '维护',
@@ -101,6 +102,8 @@ export default {
     now: '现在',
     now: '现在',
     collapse: '收起',
     collapse: '收起',
     expand: '展开',
     expand: '展开',
+    previous: '上一页',
+    next: '下一页',
     viewArchive: '查看归档',
     viewArchive: '查看归档',
     viewInFileManager: '在文件管理器中查看',
     viewInFileManager: '在文件管理器中查看',
     addedBy: '由 {{username}} 添加',
     addedBy: '由 {{username}} 添加',
@@ -1044,6 +1047,39 @@ export default {
     dismiss: '关闭',
     dismiss: '关闭',
   },
   },
 
 
+  // Pipeline Runs dashboard (#1425 PR C).
+  pipelineRuns: {
+    title: '流水线运行',
+    loading: '加载中…',
+    empty: '暂无流水线运行。',
+    filter: {
+      pipeline: '流水线',
+      status: '状态',
+      all: '全部',
+    },
+    copies: '{{n}} 份',
+    failedCount: '{{n}} 失败',
+    copyN: '副本 {{n}}',
+    retryFailed: '重试失败的',
+    retryOf: '#{{n}} 的重试',
+    pagination: '{{total}} 中的 {{start}}–{{end}}',
+    toast: {
+      cancelled: '运行已取消',
+      cancelFailed: '取消失败',
+      retryStarted: '已开始重试',
+      retryFailed: '重试失败',
+    },
+    jobStatus: {
+      pending: '等待中',
+      awaiting_printer: '等待打印机',
+      queued: '排队中',
+      printing: '打印中',
+      completed: '已完成',
+      failed: '失败',
+      cancelled: '已取消',
+    },
+  },
+
   // Queue page
   // Queue page
   queue: {
   queue: {
     filamentShort: {
     filamentShort: {
@@ -2542,6 +2578,12 @@ export default {
     },
     },
 
 
 
 
+    pipelineLimits: {
+      title: '切片机流水线限制',
+      maxCopiesLabel: '每次运行最大副本数',
+      maxCopiesDesc: '操作员运行流水线时可请求的副本数上限。服务器端硬性上限为 1000。',
+    },
+
     // Slicer Pipelines (#1425): list/edit/delete preset bundles users saved
     // Slicer Pipelines (#1425): list/edit/delete preset bundles users saved
     // from the Slice dialog. Lives in Settings → Workflow → Pipelines sub-tab.
     // from the Slice dialog. Lives in Settings → Workflow → Pipelines sub-tab.
     pipelines: {
     pipelines: {
@@ -2560,6 +2602,16 @@ export default {
         description: '描述',
         description: '描述',
         targetPrinter: '目标打印机',
         targetPrinter: '目标打印机',
         noTarget: '— 无目标 —',
         noTarget: '— 无目标 —',
+        targetKind: '目标类型',
+        targetKindSpecific: '特定打印机',
+        targetKindClass: '打印机类别',
+        targetModelClass: '打印机型号',
+        fanoutStrategy: '分发策略',
+        fanout: {
+          max_parallel: '最大并行 — 分发到任何空闲匹配的打印机',
+          round_robin: '轮询 — 在合格打印机间循环',
+          fill_one_first: '先填一台 — 将所有副本固定到一台打印机',
+        },
       },
       },
       action: {
       action: {
         save: '保存',
         save: '保存',
@@ -2591,6 +2643,7 @@ export default {
           in_progress: '打印中',
           in_progress: '打印中',
           completed: '已完成',
           completed: '已完成',
           failed: '失败',
           failed: '失败',
+          partial_failure: '部分失败',
           cancelled: '已取消',
           cancelled: '已取消',
         },
         },
       },
       },
@@ -3839,6 +3892,9 @@ export default {
       empty: '尚未保存流水线。请打开切片对话框并点击"另存为流水线"创建一个。',
       empty: '尚未保存流水线。请打开切片对话框并点击"另存为流水线"创建一个。',
       noTarget: '未设置目标打印机',
       noTarget: '未设置目标打印机',
       noTargetMessage: '此流水线没有目标打印机。请在设置中打开并选择一个。',
       noTargetMessage: '此流水线没有目标打印机。请在设置中打开并选择一个。',
+      copies: '副本数',
+      copiesHint: '最多 {{n}}',
+      classTarget: '任意 {{model}}',
       toast: {
       toast: {
         started: '流水线运行已开始',
         started: '流水线运行已开始',
         failed: '无法启动运行',
         failed: '无法启动运行',
@@ -3852,6 +3908,8 @@ export default {
         filamentColor: '耗材槽 {{slot}}:颜色不同(期望 {{expected}},AMS 实际 {{actual}})',
         filamentColor: '耗材槽 {{slot}}:颜色不同(期望 {{expected}},AMS 实际 {{actual}})',
         amsSlotMissing: '此打印机上没有 AMS 槽 {{slot}}',
         amsSlotMissing: '此打印机上没有 AMS 槽 {{slot}}',
         filamentUnverified: '耗材槽 {{slot}} 来自云端 / 标准预设,无法静态验证。',
         filamentUnverified: '耗材槽 {{slot}} 来自云端 / 标准预设,无法静态验证。',
+        noClassMatches: '此安装中没有匹配该流水线目标型号类别({{expected}})的打印机。',
+        classNotSet: '流水线目标设置为打印机类别但未选择型号。',
       },
       },
     },
     },
   },
   },

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

@@ -4,6 +4,7 @@ export default {
     printers: '印表機',
     printers: '印表機',
     archives: '歸檔',
     archives: '歸檔',
     queue: '列印佇列',
     queue: '列印佇列',
+    pipelineRuns: '管線執行',
     stats: '統計',
     stats: '統計',
     profiles: '設定檔案',
     profiles: '設定檔案',
     maintenance: '維護',
     maintenance: '維護',
@@ -101,6 +102,8 @@ export default {
     now: '現在',
     now: '現在',
     collapse: '收起',
     collapse: '收起',
     expand: '展開',
     expand: '展開',
+    previous: '上一頁',
+    next: '下一頁',
     viewArchive: '檢視歸檔',
     viewArchive: '檢視歸檔',
     viewInFileManager: '在檔案管理器中檢視',
     viewInFileManager: '在檔案管理器中檢視',
     addedBy: '由 {{username}} 新增',
     addedBy: '由 {{username}} 新增',
@@ -1044,6 +1047,39 @@ export default {
     dismiss: '關閉',
     dismiss: '關閉',
   },
   },
 
 
+  // Pipeline Runs dashboard (#1425 PR C).
+  pipelineRuns: {
+    title: '管線執行',
+    loading: '載入中…',
+    empty: '尚無管線執行。',
+    filter: {
+      pipeline: '管線',
+      status: '狀態',
+      all: '全部',
+    },
+    copies: '{{n}} 份',
+    failedCount: '{{n}} 失敗',
+    copyN: '副本 {{n}}',
+    retryFailed: '重試失敗的',
+    retryOf: '#{{n}} 的重試',
+    pagination: '{{total}} 中的 {{start}}–{{end}}',
+    toast: {
+      cancelled: '執行已取消',
+      cancelFailed: '取消失敗',
+      retryStarted: '已開始重試',
+      retryFailed: '重試失敗',
+    },
+    jobStatus: {
+      pending: '等待中',
+      awaiting_printer: '等待印表機',
+      queued: '排隊中',
+      printing: '列印中',
+      completed: '已完成',
+      failed: '失敗',
+      cancelled: '已取消',
+    },
+  },
+
   // Queue page
   // Queue page
   queue: {
   queue: {
     filamentShort: {
     filamentShort: {
@@ -2542,6 +2578,12 @@ export default {
     },
     },
 
 
 
 
+    pipelineLimits: {
+      title: '切片機管線限制',
+      maxCopiesLabel: '每次執行最大副本數',
+      maxCopiesDesc: '操作員執行管線時可請求的副本數上限。伺服器端硬性上限為 1000。',
+    },
+
     // Slicer Pipelines (#1425): list/edit/delete preset bundles users saved
     // Slicer Pipelines (#1425): list/edit/delete preset bundles users saved
     // from the Slice dialog. Lives in Settings → Workflow → Pipelines sub-tab.
     // from the Slice dialog. Lives in Settings → Workflow → Pipelines sub-tab.
     pipelines: {
     pipelines: {
@@ -2560,6 +2602,16 @@ export default {
         description: '描述',
         description: '描述',
         targetPrinter: '目標印表機',
         targetPrinter: '目標印表機',
         noTarget: '— 無目標 —',
         noTarget: '— 無目標 —',
+        targetKind: '目標類型',
+        targetKindSpecific: '特定印表機',
+        targetKindClass: '印表機類別',
+        targetModelClass: '印表機型號',
+        fanoutStrategy: '分發策略',
+        fanout: {
+          max_parallel: '最大並行 — 分發到任何空閒匹配的印表機',
+          round_robin: '輪替 — 在合格印表機間循環',
+          fill_one_first: '先填一台 — 將所有副本固定到一台印表機',
+        },
       },
       },
       action: {
       action: {
         save: '儲存',
         save: '儲存',
@@ -2591,6 +2643,7 @@ export default {
           in_progress: '列印中',
           in_progress: '列印中',
           completed: '已完成',
           completed: '已完成',
           failed: '失敗',
           failed: '失敗',
+          partial_failure: '部分失敗',
           cancelled: '已取消',
           cancelled: '已取消',
         },
         },
       },
       },
@@ -3839,6 +3892,9 @@ export default {
       empty: '尚未儲存管線。請開啟切片對話框並點擊「另存為管線」建立一個。',
       empty: '尚未儲存管線。請開啟切片對話框並點擊「另存為管線」建立一個。',
       noTarget: '未設定目標印表機',
       noTarget: '未設定目標印表機',
       noTargetMessage: '此管線沒有目標印表機。請在設定中開啟並選擇一個。',
       noTargetMessage: '此管線沒有目標印表機。請在設定中開啟並選擇一個。',
+      copies: '副本數',
+      copiesHint: '最多 {{n}}',
+      classTarget: '任意 {{model}}',
       toast: {
       toast: {
         started: '管線執行已開始',
         started: '管線執行已開始',
         failed: '無法啟動執行',
         failed: '無法啟動執行',
@@ -3852,6 +3908,8 @@ export default {
         filamentColor: '耗材槽 {{slot}}:顏色不同(預期 {{expected}},AMS 實際 {{actual}})',
         filamentColor: '耗材槽 {{slot}}:顏色不同(預期 {{expected}},AMS 實際 {{actual}})',
         amsSlotMissing: '此印表機上沒有 AMS 槽 {{slot}}',
         amsSlotMissing: '此印表機上沒有 AMS 槽 {{slot}}',
         filamentUnverified: '耗材槽 {{slot}} 來自雲端 / 標準預設,無法靜態驗證。',
         filamentUnverified: '耗材槽 {{slot}} 來自雲端 / 標準預設,無法靜態驗證。',
+        noClassMatches: '此安裝中沒有符合該管線目標型號類別({{expected}})的印表機。',
+        classNotSet: '管線目標設定為印表機類別但未選擇型號。',
       },
       },
     },
     },
   },
   },

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

@@ -0,0 +1,378 @@
+import { useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import {
+  ChevronDown,
+  ChevronRight,
+  Loader2,
+  RefreshCw,
+  RotateCcw,
+  Workflow,
+  X,
+} from 'lucide-react';
+import { api, type PipelineRun, type SlicerPipeline } from '../api/client';
+import { useToast } from '../contexts/ToastContext';
+
+const STATUSES = [
+  '',
+  'queued',
+  'slicing',
+  'dispatching',
+  'in_progress',
+  'completed',
+  'partial_failure',
+  'failed',
+  'cancelled',
+] as const;
+
+const PAGE_LIMIT = 25;
+
+// Dashboard for Slicer Pipeline runs (#1425 PR C).
+// Lists every run across every pipeline with status + pipeline filters and
+// pagination. Each row expands to show per-copy status; in-flight runs get a
+// Cancel button, partial-failure runs get a Retry-failed button.
+export function PipelineRunsPage() {
+  const { t } = useTranslation();
+  const queryClient = useQueryClient();
+  const { showToast } = useToast();
+
+  const [statusFilter, setStatusFilter] = useState<string>('');
+  const [pipelineFilter, setPipelineFilter] = useState<number | null>(null);
+  const [offset, setOffset] = useState(0);
+  const [expanded, setExpanded] = useState<Set<number>>(new Set());
+
+  const { data: pipelines } = useQuery({
+    queryKey: ['slicer-pipelines'],
+    queryFn: () => api.listSlicerPipelines(),
+  });
+
+  const { data: runsList, isLoading } = useQuery({
+    queryKey: ['pipeline-runs-all', statusFilter, pipelineFilter, offset],
+    queryFn: () =>
+      api.listAllPipelineRuns({
+        limit: PAGE_LIMIT,
+        offset,
+        pipelineId: pipelineFilter ?? undefined,
+        status: statusFilter || undefined,
+      }),
+    refetchInterval: 15_000,
+  });
+
+  const cancelMutation = useMutation({
+    mutationFn: (runId: number) => api.cancelPipelineRun(runId),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['pipeline-runs-all'] });
+      showToast(t('pipelineRuns.toast.cancelled', 'Run cancelled'), 'success');
+    },
+    onError: (err: Error) =>
+      showToast(err.message || t('pipelineRuns.toast.cancelFailed', 'Cancel failed'), 'error'),
+  });
+
+  const retryMutation = useMutation({
+    mutationFn: (runId: number) => api.retryFailedPipelineRun(runId),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['pipeline-runs-all'] });
+      showToast(t('pipelineRuns.toast.retryStarted', 'Retry started'), 'success');
+    },
+    onError: (err: Error) =>
+      showToast(err.message || t('pipelineRuns.toast.retryFailed', 'Retry failed'), 'error'),
+  });
+
+  const runs = runsList?.runs ?? [];
+  const total = runsList?.total ?? 0;
+  const pipelinesById: Record<number, SlicerPipeline> = (pipelines?.pipelines ?? []).reduce(
+    (acc, p) => {
+      acc[p.id] = p;
+      return acc;
+    },
+    {} as Record<number, SlicerPipeline>,
+  );
+
+  const toggle = (runId: number) => {
+    setExpanded((prev) => {
+      const next = new Set(prev);
+      if (next.has(runId)) next.delete(runId);
+      else next.add(runId);
+      return next;
+    });
+  };
+
+  return (
+    <div className="px-4 py-6 max-w-7xl mx-auto">
+      <div className="flex items-center justify-between mb-4">
+        <h1 className="text-xl font-bold text-white flex items-center gap-2">
+          <Workflow className="w-5 h-5 text-bambu-green" />
+          {t('pipelineRuns.title', 'Pipeline Runs')}
+        </h1>
+        <button
+          type="button"
+          onClick={() => queryClient.invalidateQueries({ queryKey: ['pipeline-runs-all'] })}
+          aria-label={t('common.refresh', 'Refresh')}
+          className="p-2 text-bambu-gray hover:text-white"
+        >
+          <RefreshCw className="w-4 h-4" />
+        </button>
+      </div>
+
+      <div className="flex flex-wrap items-center gap-2 mb-4 text-sm">
+        <label className="text-bambu-gray">
+          {t('pipelineRuns.filter.pipeline', 'Pipeline')}:{' '}
+          <select
+            value={pipelineFilter ?? ''}
+            onChange={(e) => {
+              setOffset(0);
+              setPipelineFilter(e.target.value ? parseInt(e.target.value, 10) : null);
+            }}
+            aria-label={t('pipelineRuns.filter.pipeline', 'Pipeline')}
+            className="ml-1 px-2 py-1 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-xs"
+          >
+            <option value="">{t('pipelineRuns.filter.all', 'All')}</option>
+            {(pipelines?.pipelines ?? []).map((p) => (
+              <option key={p.id} value={p.id}>
+                {p.name}
+              </option>
+            ))}
+          </select>
+        </label>
+        <label className="text-bambu-gray">
+          {t('pipelineRuns.filter.status', 'Status')}:{' '}
+          <select
+            value={statusFilter}
+            onChange={(e) => {
+              setOffset(0);
+              setStatusFilter(e.target.value);
+            }}
+            aria-label={t('pipelineRuns.filter.status', 'Status')}
+            className="ml-1 px-2 py-1 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-xs"
+          >
+            {STATUSES.map((s) => (
+              <option key={s} value={s}>
+                {s === '' ? t('pipelineRuns.filter.all', 'All') : t(`settings.pipelines.runs.status.${s}`, s)}
+              </option>
+            ))}
+          </select>
+        </label>
+      </div>
+
+      {isLoading && (
+        <div className="flex items-center gap-2 text-bambu-gray">
+          <Loader2 className="w-4 h-4 animate-spin" />
+          {t('pipelineRuns.loading', 'Loading…')}
+        </div>
+      )}
+      {!isLoading && runs.length === 0 && (
+        <p className="text-sm text-bambu-gray">{t('pipelineRuns.empty', 'No pipeline runs yet.')}</p>
+      )}
+
+      {!isLoading && runs.length > 0 && (
+        <div className="space-y-2">
+          {runs.map((run) => (
+            <RunRow
+              key={run.id}
+              run={run}
+              pipeline={run.pipeline_id ? pipelinesById[run.pipeline_id] : undefined}
+              expanded={expanded.has(run.id)}
+              onToggle={() => toggle(run.id)}
+              onCancel={() => cancelMutation.mutate(run.id)}
+              onRetry={() => retryMutation.mutate(run.id)}
+              cancelling={cancelMutation.isPending}
+              retrying={retryMutation.isPending}
+            />
+          ))}
+        </div>
+      )}
+
+      {!isLoading && total > PAGE_LIMIT && (
+        <div className="flex items-center justify-between mt-4 text-sm">
+          <button
+            type="button"
+            onClick={() => setOffset(Math.max(0, offset - PAGE_LIMIT))}
+            disabled={offset === 0}
+            className="px-3 py-1.5 rounded border border-bambu-dark-tertiary disabled:opacity-50 text-bambu-gray hover:text-white"
+          >
+            {t('common.previous', 'Previous')}
+          </button>
+          <span className="text-bambu-gray text-xs">
+            {t('pipelineRuns.pagination', '{{start}}–{{end}} of {{total}}', {
+              start: offset + 1,
+              end: Math.min(offset + PAGE_LIMIT, total),
+              total,
+            })}
+          </span>
+          <button
+            type="button"
+            onClick={() => setOffset(offset + PAGE_LIMIT)}
+            disabled={offset + PAGE_LIMIT >= total}
+            className="px-3 py-1.5 rounded border border-bambu-dark-tertiary disabled:opacity-50 text-bambu-gray hover:text-white"
+          >
+            {t('common.next', 'Next')}
+          </button>
+        </div>
+      )}
+    </div>
+  );
+}
+
+function RunRow({
+  run,
+  pipeline,
+  expanded,
+  onToggle,
+  onCancel,
+  onRetry,
+  cancelling,
+  retrying,
+}: {
+  run: PipelineRun;
+  pipeline: SlicerPipeline | undefined;
+  expanded: boolean;
+  onToggle: () => void;
+  onCancel: () => void;
+  onRetry: () => void;
+  cancelling: boolean;
+  retrying: boolean;
+}) {
+  const { t } = useTranslation();
+  const inFlight = ['queued', 'slicing', 'dispatching', 'in_progress'].includes(run.status);
+  const partial = run.status === 'partial_failure' || run.status === 'failed';
+
+  return (
+    <div className="rounded-md border border-bambu-dark-tertiary bg-bambu-dark/40">
+      <div className="flex items-center gap-2 px-3 py-2">
+        <button
+          type="button"
+          onClick={onToggle}
+          aria-label={expanded ? t('common.collapse', 'Collapse') : t('common.expand', 'Expand')}
+          aria-expanded={expanded}
+          className="text-bambu-gray hover:text-white"
+        >
+          {expanded ? <ChevronDown className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
+        </button>
+        <div className="flex-1 min-w-0">
+          <div className="flex items-center gap-2 text-sm">
+            <span className="font-medium text-white truncate">
+              #{run.id} · {pipeline?.name ?? run.pipeline_name ?? '—'}
+            </span>
+            <RunStatusChip status={run.status} />
+            {run.parent_run_id && (
+              <span className="text-xs text-bambu-gray/60">
+                ({t('pipelineRuns.retryOf', 'retry of #{{n}}', { n: run.parent_run_id })})
+              </span>
+            )}
+          </div>
+          <div className="text-xs text-bambu-gray mt-0.5">
+            {run.source_filename ?? '—'} · {new Date(run.created_at).toLocaleString()}
+            {run.copies > 1 && (
+              <>
+                {' '}· {t('pipelineRuns.copies', '{{n}} copies', { n: run.copies })}
+              </>
+            )}
+            {(run.copies_completed > 0 || run.copies_failed > 0) && (
+              <>
+                {' '}·{' '}
+                <span className="text-bambu-green">
+                  {run.copies_completed}
+                </span>
+                /{run.copies}
+                {run.copies_failed > 0 && (
+                  <>
+                    {' '}·{' '}
+                    <span className="text-red-400">
+                      {t('pipelineRuns.failedCount', '{{n}} failed', { n: run.copies_failed })}
+                    </span>
+                  </>
+                )}
+              </>
+            )}
+          </div>
+        </div>
+        <div className="flex items-center gap-1">
+          {inFlight && (
+            <button
+              type="button"
+              onClick={onCancel}
+              disabled={cancelling}
+              aria-label={t('common.cancel', 'Cancel')}
+              className="text-xs px-2 py-1 text-red-400 hover:bg-bambu-dark-tertiary rounded disabled:opacity-50 flex items-center gap-1"
+            >
+              <X className="w-3 h-3" />
+              {t('common.cancel', 'Cancel')}
+            </button>
+          )}
+          {partial && (
+            <button
+              type="button"
+              onClick={onRetry}
+              disabled={retrying}
+              aria-label={t('pipelineRuns.retryFailed', 'Retry failed')}
+              className="text-xs px-2 py-1 text-bambu-green hover:bg-bambu-dark-tertiary rounded disabled:opacity-50 flex items-center gap-1"
+            >
+              <RotateCcw className="w-3 h-3" />
+              {t('pipelineRuns.retryFailed', 'Retry failed')}
+            </button>
+          )}
+        </div>
+      </div>
+      {expanded && (
+        <div className="border-t border-bambu-dark-tertiary px-3 py-2 space-y-1">
+          {run.jobs.map((job) => (
+            <div key={job.id} className="flex items-center gap-2 text-xs text-bambu-gray">
+              <span className="text-bambu-gray/60 w-12">
+                {t('pipelineRuns.copyN', 'Copy {{n}}', { n: job.copy_index + 1 })}
+              </span>
+              <JobStatusChip status={job.status} />
+              {job.assigned_printer_name && (
+                <span className="text-white">{job.assigned_printer_name}</span>
+              )}
+              {job.error_message && (
+                <span className="text-red-400 truncate">{job.error_message}</span>
+              )}
+            </div>
+          ))}
+          {run.error_message && (
+            <div className="text-xs text-red-400 mt-1">
+              {run.error_message}
+            </div>
+          )}
+        </div>
+      )}
+    </div>
+  );
+}
+
+function RunStatusChip({ status }: { status: PipelineRun['status'] }) {
+  const { t } = useTranslation();
+  const colours: Record<PipelineRun['status'], string> = {
+    queued: 'bg-bambu-gray/20 text-bambu-gray',
+    slicing: 'bg-blue-500/20 text-blue-300',
+    dispatching: 'bg-blue-500/20 text-blue-300',
+    in_progress: 'bg-bambu-green/20 text-bambu-green',
+    completed: 'bg-bambu-green/20 text-bambu-green',
+    failed: 'bg-red-500/20 text-red-300',
+    partial_failure: 'bg-amber-500/20 text-amber-300',
+    cancelled: 'bg-bambu-gray/20 text-bambu-gray',
+  };
+  return (
+    <span className={`px-1.5 py-0.5 rounded text-[10px] uppercase ${colours[status]}`}>
+      {t(`settings.pipelines.runs.status.${status}`, status)}
+    </span>
+  );
+}
+
+function JobStatusChip({ status }: { status: PipelineRun['jobs'][number]['status'] }) {
+  const { t } = useTranslation();
+  const colours: Record<PipelineRun['jobs'][number]['status'], string> = {
+    pending: 'text-bambu-gray',
+    awaiting_printer: 'text-blue-300',
+    queued: 'text-blue-300',
+    printing: 'text-bambu-green',
+    completed: 'text-bambu-green',
+    failed: 'text-red-400',
+    cancelled: 'text-bambu-gray',
+  };
+  return (
+    <span className={colours[status]}>
+      {t(`pipelineRuns.jobStatus.${status}`, status)}
+    </span>
+  );
+}

+ 41 - 0
frontend/src/pages/SettingsPage.tsx

@@ -4425,6 +4425,47 @@ export function SettingsPage() {
           </div>
           </div>
           {/* Right Column */}
           {/* Right Column */}
           <div className="lg:w-1/2 space-y-3">
           <div className="lg:w-1/2 space-y-3">
+
+          {/* Slicer Pipelines (#1425 PR C). Cap on the copies input in
+              the Run-with-pipeline modal to prevent fat-fingered queue
+              floods. Hard ceiling at 1000 enforced server-side. */}
+          <Card id="card-pipelines">
+            <CardHeader>
+              <h3 className="text-base font-semibold text-white flex items-center gap-2">
+                <Workflow className="w-4 h-4 text-bambu-green" />
+                {t('settings.pipelineLimits.title', 'Slicer Pipeline limits')}
+              </h3>
+            </CardHeader>
+            <CardContent className="space-y-2">
+              <div className="flex items-center justify-between gap-3">
+                <div className="flex-1">
+                  <p className="text-sm text-white">
+                    {t('settings.pipelineLimits.maxCopiesLabel', 'Max copies per run')}
+                  </p>
+                  <p className="text-xs text-bambu-gray mt-0.5">
+                    {t(
+                      'settings.pipelineLimits.maxCopiesDesc',
+                      'Upper bound on the copies operators can request when running a pipeline. Server-side hard cap is 1000.',
+                    )}
+                  </p>
+                </div>
+                <input
+                  type="number"
+                  min={1}
+                  max={1000}
+                  value={localSettings.pipeline_max_copies ?? 50}
+                  onChange={(e) => {
+                    const n = parseInt(e.target.value, 10);
+                    if (Number.isNaN(n)) return;
+                    updateSetting('pipeline_max_copies', Math.max(1, Math.min(1000, n)));
+                  }}
+                  aria-label={t('settings.pipelineLimits.maxCopiesLabel', 'Max copies per run')}
+                  className="w-24 px-2 py-1 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-sm"
+                />
+              </div>
+            </CardContent>
+          </Card>
+
           {/* Slicer */}
           {/* Slicer */}
           <Card id="card-slicer">
           <Card id="card-slicer">
             <CardHeader>
             <CardHeader>

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/index-B002rfYt.css


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/index-CWnKKhV6.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-CEw4Iy3m.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-Bydn78aP.css">
+    <script type="module" crossorigin src="/assets/index-CWnKKhV6.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-B002rfYt.css">
   </head>
   </head>
   <body>
   <body>
     <div id="root"></div>
     <div id="root"></div>

Některé soubory nejsou zobrazeny, neboť je v těchto rozdílových datech změněno mnoho souborů