Selaa lähdekoodia

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 2 kuukautta sitten
vanhempi
sitoutus
3ef197e4e0
37 muutettua tiedostoa jossa 2343 lisäystä ja 233 poistoa
  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

Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 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
@@ -24,13 +30,14 @@ from pathlib import Path
 from typing import Literal
 
 from fastapi import APIRouter, Depends, HTTPException
-from sqlalchemy import select
+from sqlalchemy import desc, func, select
 from sqlalchemy.ext.asyncio import AsyncSession
 
 from backend.app.core.auth import RequirePermissionIfAuthEnabled
 from backend.app.core.config import settings as app_settings
 from backend.app.core.database import async_session, get_db
 from backend.app.core.permissions import Permission
+from backend.app.core.websocket import ws_manager
 from backend.app.models.archive import PrintArchive
 from backend.app.models.library import LibraryFile
 from backend.app.models.pipeline_run import PipelineJob, PipelineRun
@@ -42,6 +49,7 @@ from backend.app.schemas.pipeline_run import (
     CheckEligibilityRequest,
     EligibilityIssueResponse,
     EligibilityReportResponse,
+    PerPrinterReport as PerPrinterReportResponse,
     PipelineJobResponse,
     PipelineRunCreateRequest,
     PipelineRunListResponse,
@@ -56,9 +64,6 @@ from backend.app.services.pipeline_eligibility import (
 logger = logging.getLogger(__name__)
 
 
-# Two routers — one for the per-pipeline endpoints (mounted under the
-# existing ``/slicer-pipelines`` prefix) and one for the per-run endpoints
-# at ``/pipeline-runs``. Keeps the URL shape natural.
 pipeline_run_create_router = APIRouter(prefix="/slicer-pipelines", tags=["Slicer Pipelines"])
 pipeline_run_router = APIRouter(prefix="/pipeline-runs", tags=["Slicer Pipelines"])
 
@@ -71,8 +76,10 @@ pipeline_run_router = APIRouter(prefix="/pipeline-runs", tags=["Slicer Pipelines
 def _serialise_status(report: EligibilityReport) -> EligibilityReportResponse:
     return EligibilityReportResponse(
         ok=report.ok,
+        target_kind=report.target_kind,
         target_printer_id=report.target_printer_id,
         target_printer_name=report.target_printer_name,
+        target_model_class=report.target_model_class,
         issues=[
             EligibilityIssueResponse(
                 kind=issue.kind,
@@ -82,6 +89,23 @@ def _serialise_status(report: EligibilityReport) -> EligibilityReportResponse:
             )
             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:
     """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:
         return None
     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}
 
 
+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:
-    """Materialise a SliceRequest from a pipeline so we can hand it to
-    ``slice_and_persist`` exactly the way the existing SliceModal flow does."""
     try:
         raw_filaments = json.loads(pipeline.filament_presets_json or "[]")
     except (json.JSONDecodeError, TypeError):
@@ -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(
     persisted: str,
     queue_entry: PrintQueueItem | None,
@@ -173,13 +191,61 @@ def _compute_job_status(
     return "queued"
 
 
+def _roll_up_run_status(
+    persisted: str,
+    job_statuses: list[str],
+) -> str:
+    """Compute the run-level status from the per-job statuses.
+
+    Terminal-persisted always wins for explicit cancels / hard failures so
+    the dashboard doesn't flicker when one job's queue entry hasn't caught
+    up. Otherwise:
+      - all completed → completed
+      - any in_progress / printing / queued / dispatching → in_progress
+      - any failed alongside any completed → partial_failure
+      - all failed/cancelled → failed
+    """
+    if persisted in ("cancelled",):
+        return persisted
+    if not job_statuses:
+        return persisted
+
+    completed = sum(1 for s in job_statuses if s == "completed")
+    failed = sum(1 for s in job_statuses if s == "failed")
+    cancelled = sum(1 for s in job_statuses if s == "cancelled")
+    in_flight = sum(1 for s in job_statuses if s in ("printing", "queued", "awaiting_printer", "pending"))
+    total = len(job_statuses)
+
+    if completed == total:
+        return "completed"
+    if in_flight > 0:
+        return "in_progress" if persisted not in ("queued", "slicing", "dispatching") else persisted
+    # All copies are in terminal states.
+    if failed == 0 and cancelled == total:
+        return "cancelled"
+    if completed > 0 and (failed > 0 or cancelled > 0):
+        return "partial_failure"
+    if failed > 0:
+        return "failed"
+    return persisted
+
+
 async def _materialise_run(db: AsyncSession, run: PipelineRun) -> PipelineRunResponse:
     pipeline_name: str | None = None
+    target_kind = None
+    target_printer_id = None
+    target_model_class = None
+    fanout_strategy = None
     if run.pipeline_id:
         pipeline = (
             await db.execute(select(SlicerPipeline).where(SlicerPipeline.id == run.pipeline_id))
         ).scalar_one_or_none()
-        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
     if run.source_library_file_id:
@@ -204,7 +270,7 @@ async def _materialise_run(db: AsyncSession, run: PipelineRun) -> PipelineRunRes
     )
 
     job_responses: list[PipelineJobResponse] = []
-    rolled_up_status = run.status
+    job_live_statuses: list[str] = []
     for job in job_rows:
         queue_entry = None
         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
 
         live_job_status = _compute_job_status(job.status, queue_entry)
+        job_live_statuses.append(live_job_status)
         job_responses.append(
             PipelineJobResponse(
                 id=job.id,
@@ -232,7 +299,8 @@ async def _materialise_run(db: AsyncSession, run: PipelineRun) -> PipelineRunRes
                 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(
         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_archive_id=run.source_archive_id,
         source_filename=source_filename,
+        parent_run_id=run.parent_run_id,
         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,
         sliced_library_file_id=run.sliced_library_file_id,
         eligibility_overridden=run.eligibility_overridden,
@@ -252,9 +327,30 @@ async def _materialise_run(db: AsyncSession, run: PipelineRun) -> PipelineRunRes
         started_at=run.started_at,
         completed_at=run.completed_at,
         jobs=job_responses,
+        target_kind=target_kind,
+        target_printer_id=target_printer_id,
+        target_model_class=target_model_class,
+        fanout_strategy=fanout_strategy,
     )
 
 
+async def _publish_run_event(db: AsyncSession, run: PipelineRun) -> None:
+    """Broadcast a ``pipeline_run_updated`` event with the full materialised
+    run. Per-user routing via ``broadcast_to_user`` falls back to a global
+    broadcast when ``created_by`` is None (auth-disabled installs)."""
+    try:
+        payload = await _materialise_run(db, run)
+        await ws_manager.broadcast_to_user(
+            run.created_by,
+            {
+                "type": "pipeline_run_updated",
+                "run": payload.model_dump(mode="json"),
+            },
+        )
+    except Exception:
+        logger.exception("Failed to broadcast pipeline_run_updated for run %d", run.id)
+
+
 # ---------------------------------------------------------------------------
 # Source resolution + orchestration
 # ---------------------------------------------------------------------------
@@ -269,16 +365,13 @@ async def _resolve_source(
     library_file_id: int | None,
     archive_id: int | None,
 ) -> tuple[SourceKind, int, str, Path]:
-    """Look up the source row + on-disk path for either input kind. Raises 404
-    when the row or its file is missing — same shape as the SliceModal's flow
-    for both libraryFile and archive."""
     if library_file_id is not None:
         lib = (await db.execute(select(LibraryFile).where(LibraryFile.id == library_file_id))).scalar_one_or_none()
         if lib is None:
             raise HTTPException(404, "Source library file not found")
         src_path = (
             Path(app_settings.base_dir) / lib.file_path
-        )  # SEC-PATH-OK: lib.file_path is a LibraryFile DB column set only by the upload route (routes/library.py POST /files), which writes a UUID-named file under base_dir/library_files/ — never user-controlled at this site.
+        )  # SEC-PATH-OK: lib.file_path is a LibraryFile DB column set only by the upload route, which writes a UUID-named file under base_dir/library_files/.
         if not src_path.exists():
             raise HTTPException(404, "Source library file missing on disk")
         return ("library_file", lib.id, lib.filename, src_path)
@@ -292,13 +385,60 @@ async def _resolve_source(
         raise HTTPException(400, "Archive has no source file to slice")
     src_path = (
         Path(app_settings.base_dir) / rel
-    )  # SEC-PATH-OK: rel is archive.source_3mf_path / archive.file_path, both set by upload-time validators (_resolve_source_3mf_path + archive ingestion) that already do resolve+relative_to containment. Mirrors routes/archives.py:3955.
+    )  # SEC-PATH-OK: rel is archive.source_3mf_path / archive.file_path, both set by upload-time validators that already do resolve+relative_to containment.
     if not src_path.exists():
         raise HTTPException(404, "Archive source file missing on disk")
     name = arc.filename or arc.print_name or src_path.name
     return ("archive", arc.id, name, src_path)
 
 
+async def _pick_assignments(
+    db: AsyncSession,
+    pipeline: SlicerPipeline,
+    copies: int,
+) -> list[tuple[int | None, str | None]]:
+    """Return ``[(printer_id_or_None, target_model_or_None), ...]`` of length
+    ``copies`` per the pipeline's fanout strategy. ``target_model_class``
+    items leave ``printer_id`` None so the scheduler picks any free matching
+    printer; specific assignments fill ``printer_id``."""
+    target_kind = pipeline.target_kind or "specific_printer"
+    if target_kind == "specific_printer" or pipeline.target_printer_id is not None:
+        assert pipeline.target_printer_id is not None
+        return [(pipeline.target_printer_id, None)] * copies
+
+    # Class-targeting. Enumerate matching printers + apply the strategy.
+    matching = (
+        (
+            await db.execute(
+                select(Printer)
+                .where(Printer.model == pipeline.target_model_class)
+                .where(Printer.is_active.is_(True))
+                .order_by(Printer.id)
+            )
+        )
+        .scalars()
+        .all()
+    )
+    if not matching:
+        # Shouldn't reach here when eligibility passes, but failing gracefully
+        # is better than a TypeError on next-slot pick.
+        return [(None, pipeline.target_model_class)] * copies
+
+    strategy = pipeline.fanout_strategy or "max_parallel"
+    if strategy == "fill_one_first":
+        # Pin every copy to the first match. Scheduler dispatches them serially
+        # to that printer. If the printer breaks, copies wait; that's the
+        # documented trade-off.
+        return [(matching[0].id, None)] * copies
+    if strategy == "round_robin":
+        # Cycle through eligible printers — copy ``i`` lands on
+        # ``matching[i % len(matching)]``. Each item gets a fixed printer_id.
+        return [(matching[i % len(matching)].id, None) for i in range(copies)]
+    # max_parallel — leave printer_id=None, set target_model so the scheduler
+    # picks any free X1C / P1S / … for each item independently.
+    return [(None, pipeline.target_model_class)] * copies
+
+
 def _make_orchestration_callable(
     *,
     run_id: int,
@@ -307,18 +447,13 @@ def _make_orchestration_callable(
     src_id: int,
     src_filename: str,
     src_path: Path,
-    target_printer_id: int,
     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:
-        # Local import — slice_and_persist lives in routes/library which
-        # imports back into this module transitively via slicer_pipelines.
         from backend.app.api.routes.library import slice_and_persist
 
         async with async_session() as session:
@@ -327,25 +462,17 @@ def _make_orchestration_callable(
                 await session.execute(select(SlicerPipeline).where(SlicerPipeline.id == pipeline_id))
             ).scalar_one_or_none()
             if run is None or pipeline is None:
-                logger.warning(
-                    "pipeline_run %d or pipeline %d disappeared mid-orchestration",
-                    run_id,
-                    pipeline_id,
-                )
+                logger.warning("pipeline_run %d or pipeline %d disappeared mid-orchestration", run_id, pipeline_id)
                 return {}
 
-            # Refresh the snapshot status now that slicing has actually
-            # started — the route handler already wrote slice_job_id but
-            # left status='queued' until this point.
             run.status = "slicing"
             run.started_at = datetime.now(timezone.utc)
             await session.commit()
+            await _publish_run_event(session, run)
 
             slice_request = _slice_request_from_pipeline(pipeline)
             model_bytes = src_path.read_bytes()
 
-            # Resolve the folder for the sliced output. Library sources
-            # keep their folder; archive sources fall through to root.
             folder_id: int | None = None
             if src_kind == "library_file":
                 lib = (await session.execute(select(LibraryFile).where(LibraryFile.id == src_id))).scalar_one_or_none()
@@ -365,17 +492,14 @@ def _make_orchestration_callable(
                     },
                     request=slice_request,
                     current_user_id=creator_user_id,
-                    job_id=slice_job_id,  # threads --pipe progress
+                    job_id=slice_job_id,
                 )
             except HTTPException as exc:
-                # _SliceJobError isn't directly importable here without a
-                # heavier dep; surface the slice failure cleanly and let
-                # the dispatcher's generic Exception path mark the
-                # SliceJob failed. Persist the pipeline-side state here.
                 run.status = "failed"
                 run.error_message = f"Slice failed: {exc.detail}"
                 run.completed_at = datetime.now(timezone.utc)
                 await session.commit()
+                await _publish_run_event(session, run)
                 raise
             except Exception as exc:
                 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.completed_at = datetime.now(timezone.utc)
                 await session.commit()
+                await _publish_run_event(session, run)
                 raise
 
             run.sliced_library_file_id = slice_response.library_file_id
 
-            # 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"
             await session.commit()
+            await _publish_run_event(session, run)
 
             return slice_response.model_dump()
 
@@ -435,19 +566,17 @@ async def check_eligibility(
     _: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_READ),
     db: AsyncSession = Depends(get_db),
 ):
-    """Pre-flight check before a Run-pipeline click. Returns an eligibility
-    report; the frontend uses it to show a confirmation modal if any blocking
-    issue exists. Accepts either a library file id or an archive id (XOR)."""
     pipeline = await _load_pipeline(db, pipeline_id)
-    # Source resolution — same shape as /run uses, so a 404 here means /run
-    # would also 404 and the user can't proceed.
     await _resolve_source(
         db,
         library_file_id=body.source_library_file_id,
         archive_id=body.source_archive_id,
     )
-    status = await _load_printer_status(pipeline.target_printer_id)
-    report = await check_pipeline_eligibility(db, pipeline, status)
+    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)
 
 
@@ -463,10 +592,7 @@ async def run_pipeline(
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_RUN),
     db: AsyncSession = Depends(get_db),
 ):
-    """Kick off a pipeline run. PR B: copies=1, target = pipeline's pinned
-    printer. Returns 202 immediately; the slice runs in the background via
-    ``slice_dispatch`` and the slice job id rides on the response so the
-    frontend can attach the progress toast."""
+    from backend.app.api.routes.settings import get_setting
     from backend.app.services.slice_dispatch import slice_dispatch
 
     pipeline = await _load_pipeline(db, pipeline_id)
@@ -476,24 +602,40 @@ async def run_pipeline(
         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:
         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(
             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(
         pipeline_id=pipeline.id,
         source_library_file_id=src_id if src_kind == "library_file" else None,
         source_archive_id=src_id if src_kind == "archive" else None,
-        copies=1,
+        copies=body.copies,
         status="queued",
         eligibility_overridden=(not report.ok and body.force),
         created_by=current_user.id if current_user else None,
@@ -501,19 +643,19 @@ async def run_pipeline(
     db.add(run)
     await db.flush()
 
-    job = PipelineJob(
-        pipeline_run_id=run.id,
-        copy_index=0,
-        assigned_printer_id=pipeline.target_printer_id,
-        status="pending",
-    )
-    db.add(job)
+    # One PipelineJob per copy. PR B was copies=1, PR C generalises.
+    for i in range(body.copies):
+        db.add(
+            PipelineJob(
+                pipeline_run_id=run.id,
+                copy_index=i,
+                status="pending",
+            )
+        )
     await db.commit()
     await db.refresh(run)
+    await _publish_run_event(db, run)
 
-    # Enqueue the slice job. The callable inside slice_dispatch.enqueue does
-    # the full slice → enqueue-print → state-update chain. SliceJob lifecycle
-    # drives the existing progress toast.
     orchestrate = _make_orchestration_callable(
         run_id=run.id,
         pipeline_id=pipeline.id,
@@ -521,8 +663,8 @@ async def run_pipeline(
         src_id=src_id,
         src_filename=src_filename,
         src_path=src_path,
-        target_printer_id=pipeline.target_printer_id,
         creator_user_id=current_user.id if current_user else None,
+        copies=body.copies,
     )
     slice_job = await slice_dispatch.enqueue(
         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()
         .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)
@@ -584,13 +766,13 @@ async def cancel_run(
     _: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_RUN),
     db: AsyncSession = Depends(get_db),
 ):
-    """Cancel a queued / in-flight run. Pre-slice or post-slice — semantics
-    are described on the original PR B route."""
+    """Cancel a queued / in-flight run. Cascades to all non-terminal queue
+    entries; in-flight prints continue on the printer (operator must Stop)."""
     run = (await db.execute(select(PipelineRun).where(PipelineRun.id == run_id))).scalar_one_or_none()
     if run is None:
         raise HTTPException(404, "Pipeline run not found")
 
-    if run.status in ("completed", "failed", "cancelled"):
+    if run.status in ("completed", "failed", "cancelled", "partial_failure"):
         return await _materialise_run(db, run)
 
     run.status = "cancelled"
@@ -604,10 +786,7 @@ async def cancel_run(
             queue_entry = (
                 await db.execute(select(PrintQueueItem).where(PrintQueueItem.id == job.queue_entry_id))
             ).scalar_one_or_none()
-            if queue_entry is not None and queue_entry.status in (
-                "pending",
-                "queued",
-            ):
+            if queue_entry is not None and queue_entry.status in ("pending", "queued"):
                 queue_entry.status = "cancelled"
         if job.status not in ("completed", "failed", "cancelled"):
             job.status = "cancelled"
@@ -615,4 +794,62 @@ async def cancel_run(
 
     await db.commit()
     await db.refresh(run)
+    await _publish_run_event(db, run)
     return await _materialise_run(db, run)
+
+
+@pipeline_run_router.post("/{run_id}/retry-failed", response_model=PipelineRunResponse, status_code=202)
+async def retry_failed(
+    run_id: int,
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_RUN),
+    db: AsyncSession = Depends(get_db),
+):
+    """Create a new run with copies = (failed + cancelled count) from the
+    parent. Same pipeline, same source. Eligibility re-checked at run time
+    (it might pass this time — operator may have fixed the issue)."""
+    parent = (await db.execute(select(PipelineRun).where(PipelineRun.id == run_id))).scalar_one_or_none()
+    if parent is None:
+        raise HTTPException(404, "Pipeline run not found")
+    if parent.pipeline_id is None:
+        raise HTTPException(400, "Original pipeline was deleted; cannot retry")
+    if parent.source_library_file_id is None and parent.source_archive_id is None:
+        raise HTTPException(400, "Original source was deleted; cannot retry")
+
+    # Count the parent's failed + cancelled jobs.
+    parent_jobs = (
+        (await db.execute(select(PipelineJob).where(PipelineJob.pipeline_run_id == parent.id))).scalars().all()
+    )
+    fail_count = 0
+    for j in parent_jobs:
+        queue_entry = None
+        if j.queue_entry_id:
+            queue_entry = (
+                await db.execute(select(PrintQueueItem).where(PrintQueueItem.id == j.queue_entry_id))
+            ).scalar_one_or_none()
+        live = _compute_job_status(j.status, queue_entry)
+        if live in ("failed", "cancelled"):
+            fail_count += 1
+
+    if fail_count == 0:
+        raise HTTPException(400, "No failed copies to retry")
+
+    # Build the request payload the same way the user would have via /run.
+    body = PipelineRunCreateRequest(
+        source_library_file_id=parent.source_library_file_id,
+        source_archive_id=parent.source_archive_id,
+        copies=fail_count,
+        force=True,  # operator already accepted eligibility on the parent
+    )
+
+    # Reuse the run_pipeline route logic via a direct call — keeps the
+    # orchestration single-sourced. The result inherits parent_run_id.
+    new_run_response = await run_pipeline(parent.pipeline_id, body, current_user=current_user, db=db)
+
+    # Stamp parent_run_id on the freshly-created run.
+    new_row = (await db.execute(select(PipelineRun).where(PipelineRun.id == new_run_response.id))).scalar_one_or_none()
+    if new_row is not None:
+        new_row.parent_run_id = parent.id
+        await db.commit()
+        await db.refresh(new_row)
+        return await _materialise_run(db, new_row)
+    return new_run_response

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@@ -46,6 +46,8 @@ IssueKind = Literal[
     "filament_color_mismatch",
     "ams_slot_missing",
     "filament_unverified",
+    "no_class_matches",
+    "class_not_set",
 ]
 
 
@@ -57,12 +59,25 @@ class EligibilityIssue:
     actual: str | None = None
 
 
+@dataclass(frozen=True)
+class PerPrinterReport:
+    """One row of the class-targeting eligibility breakdown."""
+
+    printer_id: int
+    printer_name: str
+    ok: bool
+    issues: tuple[EligibilityIssue, ...]
+
+
 @dataclass(frozen=True)
 class EligibilityReport:
     ok: bool
+    target_kind: Literal["specific_printer", "printer_class"]
     target_printer_id: int | None
     target_printer_name: str | None
+    target_model_class: str | None
     issues: tuple[EligibilityIssue, ...]
+    printer_reports: tuple[PerPrinterReport, ...] = ()
 
 
 # Same equivalence map as print_scheduler._canonical_filament_type but kept
@@ -143,57 +158,25 @@ async def _expected_filament(
     return (_canonical(row.filament_type or ""), _normalise_colour(row.default_filament_colour))
 
 
-async def check_pipeline_eligibility(
+async def _check_one_printer(
     db: AsyncSession,
     pipeline: SlicerPipeline,
+    printer: Printer,
     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] = []
 
-    # 1. Target printer set?
-    if pipeline.target_printer_id is None:
-        issues.append(EligibilityIssue(kind="printer_not_set"))
-        return EligibilityReport(
-            ok=False,
-            target_printer_id=None,
-            target_printer_name=None,
-            issues=tuple(issues),
-        )
-
-    printer = (await db.execute(select(Printer).where(Printer.id == pipeline.target_printer_id))).scalar_one_or_none()
-    if printer is None:
-        issues.append(EligibilityIssue(kind="printer_not_found"))
-        return EligibilityReport(
-            ok=False,
-            target_printer_id=pipeline.target_printer_id,
-            target_printer_name=None,
-            issues=tuple(issues),
-        )
-
-    target_name = printer.name
-
-    # 2. Disabled?
     if not printer.is_active:
         issues.append(EligibilityIssue(kind="printer_disabled"))
 
-    # 3. Offline?
     if not printer_raw_status or not printer_raw_status.get("connected"):
         issues.append(EligibilityIssue(kind="printer_offline"))
-        # Without live AMS state, skip slot checks — would surface as a
-        # cascade of misleading mismatches.
-        return EligibilityReport(
-            ok=not issues,
-            target_printer_id=printer.id,
-            target_printer_name=target_name,
-            issues=tuple(issues),
-        )
+        return (not issues, tuple(issues))
 
-    # 4. Per-slot filament match.
     try:
         filament_refs = json.loads(pipeline.filament_presets_json or "[]")
     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"]
+    return (not blocking_issues, tuple(issues))
+
+
+async def check_pipeline_eligibility(
+    db: AsyncSession,
+    pipeline: SlicerPipeline,
+    printer_raw_status: dict | None = None,
+    *,
+    status_lookup: object = None,
+) -> EligibilityReport:
+    """Build the eligibility report.
+
+    Two calling shapes, chosen by ``pipeline.target_kind``:
+      - ``specific_printer``: ``printer_raw_status`` carries the live
+        ``PrinterState`` dict (``connected`` + ``raw_data``) for the pinned
+        target_printer_id. PR B signature, preserved.
+      - ``printer_class``: ``status_lookup`` is a callable
+        ``(printer_id) -> dict | None`` that the matcher calls for each
+        printer whose model matches ``pipeline.target_model_class``.
+    """
+    # PR A pipelines default target_kind to 'printer_class' but PR B and
+    # earlier UI only let users pin a specific_printer; treat
+    # ``target_printer_id is not None`` as the source of truth for the
+    # specific-printer path until the editor exposes target_kind explicitly.
+    if pipeline.target_printer_id is not None or pipeline.target_kind == "specific_printer":
+        # Specific-printer branch (PR B parity).
+        if pipeline.target_printer_id is None:
+            return EligibilityReport(
+                ok=False,
+                target_kind="specific_printer",
+                target_printer_id=None,
+                target_printer_name=None,
+                target_model_class=None,
+                issues=(EligibilityIssue(kind="printer_not_set"),),
+            )
+
+        printer = (
+            await db.execute(select(Printer).where(Printer.id == pipeline.target_printer_id))
+        ).scalar_one_or_none()
+        if printer is None:
+            return EligibilityReport(
+                ok=False,
+                target_kind="specific_printer",
+                target_printer_id=pipeline.target_printer_id,
+                target_printer_name=None,
+                target_model_class=None,
+                issues=(EligibilityIssue(kind="printer_not_found"),),
+            )
+
+        ok, issues = await _check_one_printer(db, pipeline, printer, printer_raw_status)
+        return EligibilityReport(
+            ok=ok,
+            target_kind="specific_printer",
+            target_printer_id=printer.id,
+            target_printer_name=printer.name,
+            target_model_class=None,
+            issues=issues,
+        )
+
+    # Class-targeting branch (PR C).
+    if not pipeline.target_model_class:
+        return EligibilityReport(
+            ok=False,
+            target_kind="printer_class",
+            target_printer_id=None,
+            target_printer_name=None,
+            target_model_class=None,
+            issues=(EligibilityIssue(kind="class_not_set"),),
+        )
+
+    candidates = (await db.execute(select(Printer).where(Printer.model == pipeline.target_model_class))).scalars().all()
+
+    if not candidates:
+        return EligibilityReport(
+            ok=False,
+            target_kind="printer_class",
+            target_printer_id=None,
+            target_printer_name=None,
+            target_model_class=pipeline.target_model_class,
+            issues=(
+                EligibilityIssue(
+                    kind="no_class_matches",
+                    expected=pipeline.target_model_class,
+                ),
+            ),
+        )
+
+    reports: list[PerPrinterReport] = []
+    if status_lookup is None:
+        # Treat all printers as offline when no lookup was provided — keeps
+        # the matcher pure-ish for unit tests.
+        for printer in candidates:
+            ok, issues = await _check_one_printer(db, pipeline, printer, None)
+            reports.append(
+                PerPrinterReport(
+                    printer_id=printer.id,
+                    printer_name=printer.name,
+                    ok=ok,
+                    issues=issues,
+                )
+            )
+    else:
+        for printer in candidates:
+            raw = status_lookup(printer.id)
+            ok, issues = await _check_one_printer(db, pipeline, printer, raw)
+            reports.append(
+                PerPrinterReport(
+                    printer_id=printer.id,
+                    printer_name=printer.name,
+                    ok=ok,
+                    issues=issues,
+                )
+            )
 
+    any_ok = any(r.ok for r in reports)
     return EligibilityReport(
-        ok=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()
         assert body["ok"] is False
         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.integration
@@ -265,7 +270,10 @@ class TestRunPipeline:
         # Eligibility report rides in detail.
         detail = resp.json()["detail"]
         assert detail["ok"] is False
-        assert any(i["kind"] == "printer_not_set" for i in detail["issues"])
+        # printer_not_set or class_not_set — depends on the PR A default
+        # target_kind. Both mean "no target chosen yet".
+        kinds = [i["kind"] for i in detail["issues"]]
+        assert "printer_not_set" in kinds or "class_not_set" in kinds
 
     @pytest.mark.asyncio
     @pytest.mark.integration
@@ -349,7 +357,7 @@ class TestRunListAndGet:
         pipeline = await pipeline_factory()
         resp = await async_client.get(f"/api/v1/slicer-pipelines/{pipeline['id']}/runs")
         assert resp.status_code == 200
-        assert resp.json() == {"runs": []}
+        assert resp.json() == {"runs": [], "total": 0}
 
     @pytest.mark.asyncio
     @pytest.mark.integration
@@ -499,6 +507,229 @@ class TestCancelRun:
         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:
     @pytest.mark.asyncio
     @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',
   'Job', 'Modal', 'Pin', 'Pro', 'Mini', 'Studio', 'Excellent', 'Description',
   'Pipeline', 'Pipelines', 'Filament {{n}}',  // #1425 — Slicer Pipelines (FR)
+  'Copies', '{{n}} copies', 'max {{n}}',  // #1425 PR C — French uses these forms verbatim
   'Action', 'Actions', 'Date', 'Type', 'Cache', 'Service', 'Configuration',
   'Archives', 'Maintenance', 'Notifications', 'Notification', 'Position',
   'Pause', 'Solution', 'Source', 'Version', 'Format', 'Documentation',
@@ -220,6 +221,7 @@ const IT_COGNATES = [
   'AMS Filament Backup',  // Bambu Lab product/firmware feature name
   'Email',  // common loanword in Italian, used verbatim in UI labels
   'Pipeline', 'slicing',  // #1425 — Slicer Pipelines (cognate in IT)
+  'max {{n}}',  // #1425 PR C — same form in Italian (max + number)
   'Status', 'Tag', 'Tags', 'Online', 'Offline', 'Standard', 'Filament',
   'Filaments', 'Software', 'Hardware', 'Stop', 'Reset', 'Test', 'Code',
   '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 { ArchivesPage } from './pages/ArchivesPage';
 import { QueuePage } from './pages/QueuePage';
+import { PipelineRunsPage } from './pages/PipelineRunsPage';
 import { StatsPage } from './pages/StatsPage';
 import { SettingsPage } from './pages/SettingsPage';
 import { ProfilesPage } from './pages/ProfilesPage';
@@ -197,6 +198,7 @@ function App() {
                   <Route index element={<PrintersPage />} />
                   <Route path="archives" element={<ArchivesPage />} />
                   <Route path="queue" element={<QueuePage />} />
+                  <Route path="pipelines/runs" element={<PermissionRoute permission="pipelines:read"><PipelineRunsPage /></PermissionRoute>} />
                   <Route path="stats" element={<StatsPage />} />
                   <Route path="profiles" element={<ProfilesPage />} />
                   <Route path="maintenance" element={<MaintenancePage />} />

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

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

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

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

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

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

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

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

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

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

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

@@ -4,6 +4,7 @@ export default {
     printers: 'Drucker',
     archives: 'Archiv',
     queue: 'Druckwarteschlange',
+    pipelineRuns: 'Pipeline-Läufe',
     stats: 'Statistiken',
     profiles: 'Profile',
     maintenance: 'Wartung',
@@ -101,6 +102,8 @@ export default {
     now: 'Jetzt',
     collapse: 'Einklappen',
     expand: 'Ausklappen',
+    previous: 'Zurück',
+    next: 'Weiter',
     viewArchive: 'Archiv anzeigen',
     viewInFileManager: 'Im Dateimanager anzeigen',
     addedBy: 'Hinzugefügt von {{username}}',
@@ -1044,6 +1047,39 @@ export default {
     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: {
     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
     // from the Slice dialog. Lives in Settings → Workflow → Pipelines sub-tab.
     pipelines: {
@@ -2572,6 +2614,16 @@ export default {
         description: 'Beschreibung',
         targetPrinter: 'Zieldrucker',
         noTarget: '— Kein Ziel —',
+        targetKind: 'Zielart',
+        targetKindSpecific: 'Spezifischer Drucker',
+        targetKindClass: 'Druckerklasse',
+        targetModelClass: 'Druckermodell',
+        fanoutStrategy: 'Verteilungsstrategie',
+        fanout: {
+          max_parallel: 'Max parallel — auf alle verfügbaren passenden Drucker verteilen',
+          round_robin: 'Reihum — durch geeignete Drucker rotieren',
+          fill_one_first: 'Erst einen füllen — alle Kopien an einen Drucker binden',
+        },
       },
       action: {
         save: 'Speichern',
@@ -2603,6 +2655,7 @@ export default {
           in_progress: 'druckt',
           completed: 'abgeschlossen',
           failed: 'fehlgeschlagen',
+          partial_failure: 'teilweise fehlgeschlagen',
           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.',
       noTarget: 'Kein Zieldrucker festgelegt',
       noTargetMessage: 'Diese Pipeline hat keinen Zieldrucker. Öffne sie in den Einstellungen, um einen festzulegen.',
+      copies: 'Kopien',
+      copiesHint: 'max. {{n}}',
+      classTarget: 'Beliebiger {{model}}',
       toast: {
         started: 'Pipeline-Lauf gestartet',
         failed: 'Lauf konnte nicht gestartet werden',
@@ -3864,6 +3920,8 @@ export default {
         filamentColor: 'Filament-Slot {{slot}}: Farbe weicht ab (erwartet {{expected}}, AMS hat {{actual}})',
         amsSlotMissing: 'AMS-Slot {{slot}} ist auf diesem Drucker nicht verfügbar',
         filamentUnverified: 'Filament-Slot {{slot}} stammt aus einem Cloud-/Standard-Preset und konnte nicht statisch verifiziert werden.',
+        noClassMatches: 'Keine Drucker in dieser Installation entsprechen der Zielmodellklasse der Pipeline ({{expected}}).',
+        classNotSet: 'Pipeline-Ziel ist auf eine Druckerklasse gesetzt, aber kein Modell wurde gewählt.',
       },
     },
   },

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

@@ -4,6 +4,7 @@ export default {
     printers: 'Printers',
     archives: 'Archives',
     queue: 'Print Queue',
+    pipelineRuns: 'Pipeline Runs',
     stats: 'Statistics',
     profiles: 'Profiles',
     maintenance: 'Maintenance',
@@ -101,6 +102,8 @@ export default {
     now: 'Now',
     collapse: 'Collapse',
     expand: 'Expand',
+    previous: 'Previous',
+    next: 'Next',
     viewArchive: 'View archive',
     viewInFileManager: 'View in File Manager',
     addedBy: 'Added by {{username}}',
@@ -1050,6 +1053,42 @@ export default {
     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: {
     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.',
     },
 
+    // Slicer Pipeline limits (#1425 PR C). Admin-tunable cap that constrains
+    // the copies input in the Run-with-pipeline modal. Lives on the Workflow
+    // tab's Queue & Dispatch sub-tab.
+    pipelineLimits: {
+      title: 'Slicer Pipeline limits',
+      maxCopiesLabel: 'Max copies per run',
+      maxCopiesDesc: 'Upper bound on the copies operators can request when running a pipeline. Server-side hard cap is 1000.',
+    },
+
     // Slicer Pipelines (#1425): list/edit/delete preset bundles users saved
     // from the Slice dialog. Lives in Settings → Workflow → Pipelines sub-tab.
     pipelines: {
@@ -2588,6 +2636,17 @@ export default {
         description: 'Description',
         targetPrinter: 'Target printer',
         noTarget: '— No target —',
+        // PR C
+        targetKind: 'Target type',
+        targetKindSpecific: 'Specific printer',
+        targetKindClass: 'Printer class',
+        targetModelClass: 'Printer model',
+        fanoutStrategy: 'Fanout strategy',
+        fanout: {
+          max_parallel: 'Max parallel — distribute across any idle matching printer',
+          round_robin: 'Round robin — cycle through eligible printers',
+          fill_one_first: 'Fill one first — pin all copies to one printer',
+        },
       },
       action: {
         save: 'Save',
@@ -2620,6 +2679,7 @@ export default {
           in_progress: 'printing',
           completed: 'completed',
           failed: 'failed',
+          partial_failure: 'partial failure',
           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.',
       noTarget: 'No target printer set',
       noTargetMessage: 'This pipeline has no target printer set. Open it in Settings to pick one.',
+      // PR C — copies input + class-targeted pipelines.
+      copies: 'Copies',
+      copiesHint: 'max {{n}}',
+      classTarget: 'Any {{model}}',
       toast: {
         started: 'Pipeline run started',
         failed: 'Could not start run',
@@ -3884,6 +3948,9 @@ export default {
         filamentColor: 'Filament slot {{slot}}: colour differs (expected {{expected}}, AMS has {{actual}})',
         amsSlotMissing: 'AMS slot {{slot}} not available on this printer',
         filamentUnverified: 'Filament slot {{slot}} comes from a cloud / standard preset and could not be statically verified.',
+        // PR C — class targeting
+        noClassMatches: 'No printers in this install match the pipeline\'s target model class ({{expected}}).',
+        classNotSet: 'Pipeline target is set to a printer class but no model was chosen.',
       },
     },
   },

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

@@ -4,6 +4,7 @@ export default {
     printers: 'Impresoras',
     archives: 'Archivos',
     queue: 'Cola de impresión',
+    pipelineRuns: 'Ejecuciones de pipeline',
     stats: 'Estadísticas',
     profiles: 'Perfiles',
     maintenance: 'Mantenimiento',
@@ -101,6 +102,8 @@ export default {
     now: 'Ahora',
     collapse: 'Contraer',
     expand: 'Expandir',
+    previous: 'Anterior',
+    next: 'Siguiente',
     viewArchive: 'Ver archivo',
     viewInFileManager: 'Ver en el gestor de archivos',
     addedBy: 'Añadido por {{username}}',
@@ -1044,6 +1047,39 @@ export default {
     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: {
     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
     // from the Slice dialog. Lives in Settings → Workflow → Pipelines sub-tab.
     pipelines: {
@@ -2575,6 +2617,16 @@ export default {
         description: 'Descripción',
         targetPrinter: 'Impresora de destino',
         noTarget: '— Sin destino —',
+        targetKind: 'Tipo de destino',
+        targetKindSpecific: 'Impresora específica',
+        targetKindClass: 'Clase de impresora',
+        targetModelClass: 'Modelo de impresora',
+        fanoutStrategy: 'Estrategia de distribución',
+        fanout: {
+          max_parallel: 'Máximo paralelo — distribuir entre cualquier impresora libre coincidente',
+          round_robin: 'Round robin — alternar entre impresoras elegibles',
+          fill_one_first: 'Llenar una primero — fijar todas las copias a una impresora',
+        },
       },
       action: {
         save: 'Guardar',
@@ -2606,6 +2658,7 @@ export default {
           in_progress: 'imprimiendo',
           completed: 'completada',
           failed: 'fallida',
+          partial_failure: 'fallo parcial',
           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.',
       noTarget: 'Sin impresora de destino',
       noTargetMessage: 'Esta pipeline no tiene impresora de destino. Ábrela en Ajustes para elegir una.',
+      copies: 'Copias',
+      copiesHint: 'máx {{n}}',
+      classTarget: 'Cualquier {{model}}',
       toast: {
         started: 'Ejecución de pipeline iniciada',
         failed: 'No se pudo iniciar la ejecución',
@@ -3867,6 +3923,8 @@ export default {
         filamentColor: 'Slot de filamento {{slot}}: color distinto (esperado {{expected}}, AMS tiene {{actual}})',
         amsSlotMissing: 'Slot AMS {{slot}} no disponible en esta impresora',
         filamentUnverified: 'Slot de filamento {{slot}} proviene de un preset de nube / estándar y no se pudo verificar estáticamente.',
+        noClassMatches: 'Ninguna impresora en esta instalación coincide con la clase de modelo objetivo de la pipeline ({{expected}}).',
+        classNotSet: 'El destino de la pipeline es una clase de impresora pero no se eligió ningún modelo.',
       },
     },
   },

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

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

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

@@ -4,6 +4,7 @@ export default {
     printers: 'Stampanti',
     archives: 'Archivi',
     queue: 'Coda di stampa',
+    pipelineRuns: 'Esecuzioni pipeline',
     stats: 'Statistiche',
     profiles: 'Profili',
     maintenance: 'Manutenzione',
@@ -101,6 +102,8 @@ export default {
     now: 'Ora',
     collapse: 'Comprimi',
     expand: 'Espandi',
+    previous: 'Precedente',
+    next: 'Successivo',
     viewArchive: 'Vedi archivio',
     viewInFileManager: 'Vedi nel Gestore file',
     addedBy: 'Aggiunto da {{username}}',
@@ -1044,6 +1047,39 @@ export default {
     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: {
     filamentShort: {
@@ -2542,6 +2578,12 @@ export default {
       commandError: 'Invio comando non riuscito',
     },
 
+    pipelineLimits: {
+      title: 'Limiti pipeline dello slicer',
+      maxCopiesLabel: 'Copie massime per esecuzione',
+      maxCopiesDesc: 'Limite superiore alle copie che gli operatori possono richiedere durante l\'esecuzione di una pipeline. Il limite massimo lato server è 1000.',
+    },
+
     // Slicer Pipelines (#1425): list/edit/delete preset bundles users saved
     // from the Slice dialog. Lives in Settings → Workflow → Pipelines sub-tab.
     pipelines: {
@@ -2560,6 +2602,16 @@ export default {
         description: 'Descrizione',
         targetPrinter: 'Stampante di destinazione',
         noTarget: '— Nessuna destinazione —',
+        targetKind: 'Tipo destinazione',
+        targetKindSpecific: 'Stampante specifica',
+        targetKindClass: 'Classe stampante',
+        targetModelClass: 'Modello stampante',
+        fanoutStrategy: 'Strategia di distribuzione',
+        fanout: {
+          max_parallel: 'Max parallelo — distribuisci su qualsiasi stampante libera corrispondente',
+          round_robin: 'Round robin — alterna tra stampanti idonee',
+          fill_one_first: 'Riempi una prima — assegna tutte le copie a una stampante',
+        },
       },
       action: {
         save: 'Salva',
@@ -2591,6 +2643,7 @@ export default {
           in_progress: 'stampa',
           completed: 'completata',
           failed: 'fallita',
+          partial_failure: 'fallimento parziale',
           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.',
       noTarget: 'Nessuna stampante di destinazione',
       noTargetMessage: 'Questa pipeline non ha una stampante di destinazione. Aprila nelle Impostazioni per sceglierne una.',
+      copies: 'Copie',
+      copiesHint: 'max {{n}}',
+      classTarget: 'Qualsiasi {{model}}',
       toast: {
         started: 'Esecuzione della pipeline avviata',
         failed: 'Impossibile avviare l\'esecuzione',
@@ -3852,6 +3908,8 @@ export default {
         filamentColor: 'Slot filamento {{slot}}: colore differente (atteso {{expected}}, AMS ha {{actual}})',
         amsSlotMissing: 'Slot AMS {{slot}} non disponibile su questa stampante',
         filamentUnverified: 'Lo slot filamento {{slot}} proviene da un preset cloud / standard e non può essere verificato staticamente.',
+        noClassMatches: 'Nessuna stampante in questa installazione corrisponde alla classe di modello target della pipeline ({{expected}}).',
+        classNotSet: 'La destinazione della pipeline è una classe di stampante ma nessun modello è stato scelto.',
       },
     },
   },

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

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

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

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

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

@@ -4,6 +4,7 @@ export default {
     printers: 'Impressoras',
     archives: 'Arquivos',
     queue: 'Fila de impressão',
+    pipelineRuns: 'Execuções de pipeline',
     stats: 'Estatísticas',
     profiles: 'Perfis',
     maintenance: 'Manutenção',
@@ -101,6 +102,8 @@ export default {
     now: 'Agora',
     collapse: 'Recolher',
     expand: 'Expandir',
+    previous: 'Anterior',
+    next: 'Próximo',
     viewArchive: 'Ver arquivo',
     viewInFileManager: 'Ver no Gerenciador de Arquivos',
     addedBy: 'Adicionado por {{username}}',
@@ -1044,6 +1047,39 @@ export default {
     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: {
     filamentShort: {
@@ -2542,6 +2578,12 @@ export default {
       commandError: 'Falha ao enviar comando',
     },
 
+    pipelineLimits: {
+      title: 'Limites de pipelines do slicer',
+      maxCopiesLabel: 'Cópias máximas por execução',
+      maxCopiesDesc: 'Limite superior de cópias que operadores podem solicitar ao executar uma pipeline. O limite rígido no lado do servidor é 1000.',
+    },
+
     // Slicer Pipelines (#1425): list/edit/delete preset bundles users saved
     // from the Slice dialog. Lives in Settings → Workflow → Pipelines sub-tab.
     pipelines: {
@@ -2560,6 +2602,16 @@ export default {
         description: 'Descrição',
         targetPrinter: 'Impressora de destino',
         noTarget: '— Sem destino —',
+        targetKind: 'Tipo de destino',
+        targetKindSpecific: 'Impressora específica',
+        targetKindClass: 'Classe de impressora',
+        targetModelClass: 'Modelo de impressora',
+        fanoutStrategy: 'Estratégia de distribuição',
+        fanout: {
+          max_parallel: 'Máximo paralelo — distribuir em qualquer impressora ociosa compatível',
+          round_robin: 'Round robin — alternar entre impressoras elegíveis',
+          fill_one_first: 'Encher uma primeiro — fixar todas as cópias em uma impressora',
+        },
       },
       action: {
         save: 'Salvar',
@@ -2591,6 +2643,7 @@ export default {
           in_progress: 'imprimindo',
           completed: 'concluída',
           failed: 'falhou',
+          partial_failure: 'falha parcial',
           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.',
       noTarget: 'Sem impressora de destino',
       noTargetMessage: 'Esta pipeline não tem impressora de destino. Abra-a em Configurações para escolher uma.',
+      copies: 'Cópias',
+      copiesHint: 'máx {{n}}',
+      classTarget: 'Qualquer {{model}}',
       toast: {
         started: 'Execução da pipeline iniciada',
         failed: 'Não foi possível iniciar a execução',
@@ -3852,6 +3908,8 @@ export default {
         filamentColor: 'Slot de filamento {{slot}}: cor diferente (esperado {{expected}}, AMS tem {{actual}})',
         amsSlotMissing: 'Slot AMS {{slot}} indisponível nesta impressora',
         filamentUnverified: 'Slot de filamento {{slot}} vem de uma predefinição da nuvem / padrão e não pôde ser verificado estaticamente.',
+        noClassMatches: 'Nenhuma impressora nesta instalação corresponde à classe de modelo de destino da pipeline ({{expected}}).',
+        classNotSet: 'O destino da pipeline está configurado como classe de impressora, mas nenhum modelo foi escolhido.',
       },
     },
   },

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

@@ -4,6 +4,7 @@ export default {
     printers: 'Yazıcılar',
     archives: 'Arşivler',
     queue: 'Baskı Kuyruğu',
+    pipelineRuns: 'Pipeline çalıştırmaları',
     stats: 'İstatistikler',
     profiles: 'Profiller',
     maintenance: 'Bakım',
@@ -101,6 +102,8 @@ export default {
     now: 'Şimdi',
     collapse: 'Daralt',
     expand: 'Genişlet',
+    previous: 'Önceki',
+    next: 'Sonraki',
     viewArchive: 'Arşivi gör',
     viewInFileManager: 'Dosya Yöneticisinde gör',
     addedBy: '{{username}} tarafından eklendi',
@@ -1045,6 +1048,39 @@ export default {
   },
 
   // 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: {
     title: 'Baskı Kuyruğu',
     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
     // from the Slice dialog. Lives in Settings → Workflow → Pipelines sub-tab.
     pipelines: {
@@ -2576,6 +2618,16 @@ export default {
         description: 'Açıklama',
         targetPrinter: 'Hedef yazıcı',
         noTarget: '— Hedef yok —',
+        targetKind: 'Hedef türü',
+        targetKindSpecific: 'Belirli yazıcı',
+        targetKindClass: 'Yazıcı sınıfı',
+        targetModelClass: 'Yazıcı modeli',
+        fanoutStrategy: 'Dağıtım stratejisi',
+        fanout: {
+          max_parallel: 'Maksimum paralel — eşleşen boş yazıcılara dağıt',
+          round_robin: 'Sırayla — uygun yazıcılar arasında döndür',
+          fill_one_first: 'Önce birini doldur — tüm kopyaları tek yazıcıya sabitle',
+        },
       },
       action: {
         save: 'Kaydet',
@@ -2607,6 +2659,7 @@ export default {
           in_progress: 'yazdırılıyor',
           completed: 'tamamlandı',
           failed: 'başarısız',
+          partial_failure: 'kısmi başarısızlık',
           cancelled: 'iptal edildi',
         },
       },
@@ -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.',
       noTarget: 'Hedef yazıcı belirlenmemiş',
       noTargetMessage: 'Bu pipeline\'ın hedef yazıcısı yok. Birini seçmek için Ayarlar\'da açın.',
+      copies: 'Kopyalar',
+      copiesHint: 'maks {{n}}',
+      classTarget: 'Herhangi {{model}}',
       toast: {
         started: 'Pipeline çalıştırması başladı',
         failed: 'Çalıştırma başlatılamadı',
@@ -3854,6 +3910,8 @@ export default {
         filamentColor: 'Filament yuvası {{slot}}: renk farklı (beklenen {{expected}}, AMS\'de {{actual}})',
         amsSlotMissing: 'AMS yuvası {{slot}} bu yazıcıda yok',
         filamentUnverified: 'Filament yuvası {{slot}} bulut / standart ön ayardan geldiği için statik olarak doğrulanamadı.',
+        noClassMatches: 'Bu kurulumda pipeline\'ın hedef model sınıfıyla ({{expected}}) eşleşen yazıcı yok.',
+        classNotSet: 'Pipeline hedefi yazıcı sınıfı olarak ayarlanmış ancak model seçilmemiş.',
       },
     },
   },

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

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

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

@@ -4,6 +4,7 @@ export default {
     printers: '印表機',
     archives: '歸檔',
     queue: '列印佇列',
+    pipelineRuns: '管線執行',
     stats: '統計',
     profiles: '設定檔案',
     maintenance: '維護',
@@ -101,6 +102,8 @@ export default {
     now: '現在',
     collapse: '收起',
     expand: '展開',
+    previous: '上一頁',
+    next: '下一頁',
     viewArchive: '檢視歸檔',
     viewInFileManager: '在檔案管理器中檢視',
     addedBy: '由 {{username}} 新增',
@@ -1044,6 +1047,39 @@ export default {
     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: {
     filamentShort: {
@@ -2542,6 +2578,12 @@ export default {
     },
 
 
+    pipelineLimits: {
+      title: '切片機管線限制',
+      maxCopiesLabel: '每次執行最大副本數',
+      maxCopiesDesc: '操作員執行管線時可請求的副本數上限。伺服器端硬性上限為 1000。',
+    },
+
     // Slicer Pipelines (#1425): list/edit/delete preset bundles users saved
     // from the Slice dialog. Lives in Settings → Workflow → Pipelines sub-tab.
     pipelines: {
@@ -2560,6 +2602,16 @@ export default {
         description: '描述',
         targetPrinter: '目標印表機',
         noTarget: '— 無目標 —',
+        targetKind: '目標類型',
+        targetKindSpecific: '特定印表機',
+        targetKindClass: '印表機類別',
+        targetModelClass: '印表機型號',
+        fanoutStrategy: '分發策略',
+        fanout: {
+          max_parallel: '最大並行 — 分發到任何空閒匹配的印表機',
+          round_robin: '輪替 — 在合格印表機間循環',
+          fill_one_first: '先填一台 — 將所有副本固定到一台印表機',
+        },
       },
       action: {
         save: '儲存',
@@ -2591,6 +2643,7 @@ export default {
           in_progress: '列印中',
           completed: '已完成',
           failed: '失敗',
+          partial_failure: '部分失敗',
           cancelled: '已取消',
         },
       },
@@ -3839,6 +3892,9 @@ export default {
       empty: '尚未儲存管線。請開啟切片對話框並點擊「另存為管線」建立一個。',
       noTarget: '未設定目標印表機',
       noTargetMessage: '此管線沒有目標印表機。請在設定中開啟並選擇一個。',
+      copies: '副本數',
+      copiesHint: '最多 {{n}}',
+      classTarget: '任意 {{model}}',
       toast: {
         started: '管線執行已開始',
         failed: '無法啟動執行',
@@ -3852,6 +3908,8 @@ export default {
         filamentColor: '耗材槽 {{slot}}:顏色不同(預期 {{expected}},AMS 實際 {{actual}})',
         amsSlotMissing: '此印表機上沒有 AMS 槽 {{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>
           {/* Right Column */}
           <div className="lg:w-1/2 space-y-3">
+
+          {/* Slicer Pipelines (#1425 PR C). Cap on the copies input in
+              the Run-with-pipeline modal to prevent fat-fingered queue
+              floods. Hard ceiling at 1000 enforced server-side. */}
+          <Card id="card-pipelines">
+            <CardHeader>
+              <h3 className="text-base font-semibold text-white flex items-center gap-2">
+                <Workflow className="w-4 h-4 text-bambu-green" />
+                {t('settings.pipelineLimits.title', 'Slicer Pipeline limits')}
+              </h3>
+            </CardHeader>
+            <CardContent className="space-y-2">
+              <div className="flex items-center justify-between gap-3">
+                <div className="flex-1">
+                  <p className="text-sm text-white">
+                    {t('settings.pipelineLimits.maxCopiesLabel', 'Max copies per run')}
+                  </p>
+                  <p className="text-xs text-bambu-gray mt-0.5">
+                    {t(
+                      'settings.pipelineLimits.maxCopiesDesc',
+                      'Upper bound on the copies operators can request when running a pipeline. Server-side hard cap is 1000.',
+                    )}
+                  </p>
+                </div>
+                <input
+                  type="number"
+                  min={1}
+                  max={1000}
+                  value={localSettings.pipeline_max_copies ?? 50}
+                  onChange={(e) => {
+                    const n = parseInt(e.target.value, 10);
+                    if (Number.isNaN(n)) return;
+                    updateSetting('pipeline_max_copies', Math.max(1, Math.min(1000, n)));
+                  }}
+                  aria-label={t('settings.pipelineLimits.maxCopiesLabel', 'Max copies per run')}
+                  className="w-24 px-2 py-1 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-sm"
+                />
+              </div>
+            </CardContent>
+          </Card>
+
           {/* Slicer */}
           <Card id="card-slicer">
             <CardHeader>

Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
static/assets/index-B002rfYt.css


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
static/assets/index-CWnKKhV6.js


+ 2 - 2
static/index.html

@@ -26,8 +26,8 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-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>
   <body>
     <div id="root"></div>

Kaikkia tiedostoja ei voida näyttää, sillä liian monta tiedostoa muuttui tässä diffissä