Преглед на файлове

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

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

Pipelines runs dashboard
- PipelineRunsPage.tsx: the Pipeline / Status / Target filter row's three
  native <select> elements are replaced with a bambu-themed FilterDropdown
  (button trigger, floating menu, optgroup-style headers for the Target
  picker, hover + selected states with a check mark, closes on outside
  click and Escape). Same value/onChange contract -- visual only.
- SlicerPipelinesPanel.tsx: wrap list?.pipelines ?? [] in useMemo so the
  reference is stable when the data is stable. Fixes the
  react-hooks/exhaustive-deps warning where the inline fallback returned
  a fresh empty array every render, invalidating both downstream useMemo
  caches (target-options + filtered-pipelines list).
maziggy преди 2 месеца
родител
ревизия
b23cb69a66

Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
CHANGELOG.md


+ 106 - 6
backend/app/api/routes/pipeline_runs.py

@@ -30,7 +30,7 @@ from pathlib import Path
 from typing import Literal
 from typing import Literal
 
 
 from fastapi import APIRouter, Depends, HTTPException
 from fastapi import APIRouter, Depends, HTTPException
-from sqlalchemy import desc, func, select
+from sqlalchemy import delete, desc, func, select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
 
 
 from backend.app.core.auth import RequirePermissionIfAuthEnabled
 from backend.app.core.auth import RequirePermissionIfAuthEnabled
@@ -284,6 +284,16 @@ async def _materialise_run(db: AsyncSession, run: PipelineRun) -> PipelineRunRes
             printer_name = p.name if p else None
             printer_name = p.name if p else None
 
 
         live_job_status = _compute_job_status(job.status, queue_entry)
         live_job_status = _compute_job_status(job.status, queue_entry)
+        # If the job WAS dispatched (had a queue_entry_id) but the entry has
+        # since been deleted from the queue page, the user's intent was
+        # cancellation. Otherwise the run would stay forever showing as
+        # ``queued`` because the persisted job.status hasn't been updated.
+        if (
+            job.queue_entry_id is not None
+            and queue_entry is None
+            and live_job_status not in ("completed", "failed", "cancelled")
+        ):
+            live_job_status = "cancelled"
         job_live_statuses.append(live_job_status)
         job_live_statuses.append(live_job_status)
         job_responses.append(
         job_responses.append(
             PipelineJobResponse(
             PipelineJobResponse(
@@ -465,6 +475,16 @@ def _make_orchestration_callable(
                 logger.warning("pipeline_run %d or pipeline %d disappeared mid-orchestration", run_id, pipeline_id)
                 logger.warning("pipeline_run %d or pipeline %d disappeared mid-orchestration", run_id, pipeline_id)
                 return {}
                 return {}
 
 
+            # Honour a cancel that landed between ``POST /run`` returning and
+            # this background task starting. If the run was cancelled while
+            # still in ``queued`` we must NOT flip it back to ``slicing`` —
+            # the operator's intent was to stop, and overwriting status here
+            # was the bug that left runs stuck at ``dispatching`` after a
+            # user-side cancel (#1425 PR C bug report).
+            if run.status == "cancelled":
+                logger.info("pipeline_run %d was cancelled before slicing started", run_id)
+                return {}
+
             run.status = "slicing"
             run.status = "slicing"
             run.started_at = datetime.now(timezone.utc)
             run.started_at = datetime.now(timezone.utc)
             await session.commit()
             await session.commit()
@@ -512,6 +532,17 @@ def _make_orchestration_callable(
 
 
             run.sliced_library_file_id = slice_response.library_file_id
             run.sliced_library_file_id = slice_response.library_file_id
 
 
+            # Re-check cancellation: the slice can take minutes, and the
+            # operator may have hit Cancel during that window. Refresh from
+            # the DB rather than trusting our in-memory `run` (the cancel
+            # route writes via a separate session). When cancelled, don't
+            # enqueue print queue items — that's the whole point of cancel.
+            await session.refresh(run)
+            if run.status == "cancelled":
+                logger.info("pipeline_run %d cancelled mid-slice; skipping queue enqueue", run_id)
+                await session.commit()
+                return slice_response.model_dump()
+
             # PR C: enqueue N copies per the picked assignment strategy.
             # PR C: enqueue N copies per the picked assignment strategy.
             assignments = await _pick_assignments(session, pipeline, copies)
             assignments = await _pick_assignments(session, pipeline, copies)
 
 
@@ -542,9 +573,40 @@ def _make_orchestration_callable(
 
 
                 job.queue_entry_id = queue_item.id
                 job.queue_entry_id = queue_item.id
                 job.assigned_printer_id = printer_id  # may be None for max_parallel
                 job.assigned_printer_id = printer_id  # may be None for max_parallel
-                job.status = "queued"
+                # Don't write job.status yet — final cancellation check below
+                # may flip it to 'cancelled' instead. dispatched_at is fine to
+                # set unconditionally since the orchestration actually got here.
                 job.dispatched_at = datetime.now(timezone.utc)
                 job.dispatched_at = datetime.now(timezone.utc)
 
 
+            # Final cancellation check before committing 'dispatching'. The
+            # cancel route writes via a separate session so we have to refresh
+            # to see the latest. If the cancel landed in this narrow window —
+            # AFTER the post-slice refresh but BEFORE this commit — the queue
+            # entries we just created would otherwise pick up and print. Mark
+            # them + the per-copy jobs cancelled so the user's intent sticks.
+            await session.refresh(run)
+            if run.status == "cancelled":
+                logger.info(
+                    "pipeline_run %d cancelled in the dispatch window; cancelling its %d queue entries",
+                    run_id,
+                    len(jobs),
+                )
+                for job in jobs:
+                    if job.queue_entry_id:
+                        qe = (
+                            await session.execute(select(PrintQueueItem).where(PrintQueueItem.id == job.queue_entry_id))
+                        ).scalar_one_or_none()
+                        if qe is not None and qe.status in ("pending", "queued"):
+                            qe.status = "cancelled"
+                    if job.status not in ("completed", "failed", "cancelled"):
+                        job.status = "cancelled"
+                        job.completed_at = datetime.now(timezone.utc)
+                await session.commit()
+                await _publish_run_event(session, run)
+                return slice_response.model_dump()
+
+            for job in jobs:
+                job.status = "queued"
             run.status = "dispatching"
             run.status = "dispatching"
             await session.commit()
             await session.commit()
             await _publish_run_event(session, run)
             await _publish_run_event(session, run)
@@ -720,13 +782,17 @@ async def list_all_runs(
     offset: int = 0,
     offset: int = 0,
     pipeline_id: int | None = None,
     pipeline_id: int | None = None,
     status: str | None = None,
     status: str | None = None,
+    target_printer_id: int | None = None,
+    target_model_class: str | None = None,
     _: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_READ),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_READ),
     db: AsyncSession = Depends(get_db),
     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."""
+    """Dashboard list. Newest first; filters on pipeline_id + status +
+    target_printer_id + target_model_class. The ``status`` filter matches
+    the persisted snapshot, not the live roll-up — in-progress runs may
+    appear under ``dispatching`` until the next state transition writes
+    through. ``target_*`` filters JOIN to the pipeline so runs whose
+    pipeline currently points at the printer / class are returned."""
     limit = max(1, min(limit, 100))
     limit = max(1, min(limit, 100))
     offset = max(0, offset)
     offset = max(0, offset)
 
 
@@ -738,6 +804,15 @@ async def list_all_runs(
     if status:
     if status:
         stmt = stmt.where(PipelineRun.status == status)
         stmt = stmt.where(PipelineRun.status == status)
         count_stmt = count_stmt.where(PipelineRun.status == status)
         count_stmt = count_stmt.where(PipelineRun.status == status)
+    if target_printer_id is not None or target_model_class is not None:
+        stmt = stmt.join(SlicerPipeline, SlicerPipeline.id == PipelineRun.pipeline_id)
+        count_stmt = count_stmt.join(SlicerPipeline, SlicerPipeline.id == PipelineRun.pipeline_id)
+        if target_printer_id is not None:
+            stmt = stmt.where(SlicerPipeline.target_printer_id == target_printer_id)
+            count_stmt = count_stmt.where(SlicerPipeline.target_printer_id == target_printer_id)
+        if target_model_class is not None:
+            stmt = stmt.where(SlicerPipeline.target_model_class == target_model_class)
+            count_stmt = count_stmt.where(SlicerPipeline.target_model_class == target_model_class)
 
 
     rows = (await db.execute(stmt.order_by(desc(PipelineRun.id)).offset(offset).limit(limit))).scalars().all()
     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
     total = (await db.execute(count_stmt)).scalar() or 0
@@ -748,6 +823,31 @@ async def list_all_runs(
     )
     )
 
 
 
 
+_TERMINAL_RUN_STATUSES = ("completed", "failed", "cancelled", "partial_failure")
+
+
+@pipeline_run_router.post("/clear")
+async def clear_terminal_runs(
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_WRITE),
+    db: AsyncSession = Depends(get_db),
+):
+    """Delete every terminal pipeline run (completed / failed / cancelled /
+    partial_failure). In-flight runs (queued / slicing / dispatching /
+    in_progress) are preserved — clearing those mid-flight would lose the
+    operator's intent. Cascades to PipelineJob via the ondelete='CASCADE'
+    relationship; the linked PrintQueueItem rows stay (they have their own
+    lifecycle on the queue page)."""
+    # Count first so the response can report how many got cleared. Done
+    # under the same session/transaction as the delete so the numbers can't
+    # drift if another caller races in.
+    count_stmt = select(func.count()).select_from(PipelineRun).where(PipelineRun.status.in_(_TERMINAL_RUN_STATUSES))
+    n = (await db.execute(count_stmt)).scalar() or 0
+    if n > 0:
+        await db.execute(delete(PipelineRun).where(PipelineRun.status.in_(_TERMINAL_RUN_STATUSES)))
+        await db.commit()
+    return {"deleted": n}
+
+
 @pipeline_run_router.get("/{run_id}", response_model=PipelineRunResponse)
 @pipeline_run_router.get("/{run_id}", response_model=PipelineRunResponse)
 async def get_run(
 async def get_run(
     run_id: int,
     run_id: int,

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

@@ -3345,7 +3345,7 @@ async def seed_default_groups():
 
 
     from sqlalchemy import select
     from sqlalchemy import select
 
 
-    from backend.app.core.permissions import DEFAULT_GROUPS
+    from backend.app.core.permissions import ALL_PERMISSIONS, DEFAULT_GROUPS
     from backend.app.models.group import Group
     from backend.app.models.group import Group
     from backend.app.models.user import User
     from backend.app.models.user import User
 
 
@@ -3498,63 +3498,31 @@ async def seed_default_groups():
                 group.permissions = perms
                 group.permissions = perms
         await session.commit()
         await session.commit()
 
 
-        # Backfill library:purge + archives:purge for the Administrators group
-        # on existing installs. Both permissions were added after Administrators
-        # was first seeded, so upgrading users miss them even though the default
-        # config (ALL_PERMISSIONS) includes them for fresh installs.
-        result = await session.execute(select(Group).where(Group.name == "Administrators"))
-        admin_group = result.scalar_one_or_none()
-        if admin_group and admin_group.permissions is not None:
-            perms = list(admin_group.permissions)
-            added = False
-            for new_perm in ("library:purge", "archives:purge"):
-                if new_perm not in perms:
-                    perms.append(new_perm)
-                    added = True
-                    logger.info("Added %s to Administrators group (backfill)", new_perm)
-            if added:
-                admin_group.permissions = perms
-        await session.commit()
-
-        # Backfill the read flag set for the Administrators group on existing
-        # installs (maziggy/bambuddy-security #2). Two layers:
-        #
-        # (a) New OWN/ALL splits — `archives:read_own` etc. Fresh installs get
-        #     these via ALL_PERMISSIONS; upgrades need the explicit backfill
-        #     so admin's permission set matches a fresh install's.
+        # Backfill: sync the Administrators system group to ALL_PERMISSIONS.
+        # Administrators' contract is full access to every feature — fresh
+        # installs get that via DEFAULT_GROUPS["Administrators"]["permissions"]
+        # = ALL_PERMISSIONS. Upgrading installs would otherwise stay frozen at
+        # whatever permission set existed when they were first seeded, so a
+        # newly-added Permission enum member silently leaves admins gated out
+        # of the feature it controls.
         #
         #
-        # (b) Legacy `archives:read` / `library:read` / `queue:read`. The
-        #     frontend still gates download / preview UI on these LEGACY
-        #     strings (see ArchivesPage / FileManagerPage), so admin needs
-        #     them retained even though the new API uses the OWN/ALL split.
-        #     The PERMISSION_MIGRATION_ALL map deliberately doesn't rename
-        #     read flags for admin — this backfill ensures they're present
-        #     even if they were stripped by hand or by an older migration.
-        #
-        # Also includes orca_cloud:auth for parity with fresh-install
-        # behaviour (ALL_PERMISSIONS covers it; backfill makes sure an
-        # admin role that's been customised since seed still has it).
+        # Generalises the previous one-off admin backfills (library:purge,
+        # archives:purge, the OWN/ALL read-flag set + legacy read flags,
+        # orca_cloud:auth, printer_sensor_history:read, …): every current
+        # Permission enum value is appended to the admin group if missing.
+        # Additive only — never removes a permission an operator added by
+        # hand. Run AFTER the legacy-rename migration above so the renamed
+        # OWN/ALL variants land in the group before the sync sees them.
         result = await session.execute(select(Group).where(Group.name == "Administrators"))
         result = await session.execute(select(Group).where(Group.name == "Administrators"))
         admin_group = result.scalar_one_or_none()
         admin_group = result.scalar_one_or_none()
         if admin_group and admin_group.permissions is not None:
         if admin_group and admin_group.permissions is not None:
             perms = list(admin_group.permissions)
             perms = list(admin_group.permissions)
             added = False
             added = False
-            for new_perm in (
-                "archives:read",
-                "archives:read_own",
-                "archives:read_all",
-                "library:read",
-                "library:read_own",
-                "library:read_all",
-                "queue:read",
-                "queue:read_own",
-                "queue:read_all",
-                "orca_cloud:auth",
-            ):
+            for new_perm in ALL_PERMISSIONS:
                 if new_perm not in perms:
                 if new_perm not in perms:
                     perms.append(new_perm)
                     perms.append(new_perm)
                     added = True
                     added = True
-                    logger.info("Added %s to Administrators group (backfill)", new_perm)
+                    logger.info("Added %s to Administrators group (ALL_PERMISSIONS sync)", new_perm)
             if added:
             if added:
                 admin_group.permissions = perms
                 admin_group.permissions = perms
         await session.commit()
         await session.commit()
@@ -3613,25 +3581,18 @@ async def seed_default_groups():
                 group.permissions = perms
                 group.permissions = perms
         await session.commit()
         await session.commit()
 
 
-        # Backfill pipeline permissions (#1425). Pipelines were added after
-        # initial seeding, so existing groups need them appended:
-        #   - Administrators: all three (matches fresh-install ALL_PERMISSIONS)
+        # Backfill pipeline permissions (#1425) for non-admin groups.
+        # Administrators is handled by the ALL_PERMISSIONS sync above.
         #   - Operators: all three (matches fresh-install DEFAULT_GROUPS)
         #   - Operators: all three (matches fresh-install DEFAULT_GROUPS)
-        #   - Viewers + any group with library:read_own or settings:read:
+        #   - Any other group with library:read_own or settings:read:
         #     pipelines:read only
         #     pipelines:read only
         result = await session.execute(select(Group))
         result = await session.execute(select(Group))
         for group in result.scalars().all():
         for group in result.scalars().all():
-            if not group.permissions:
+            if not group.permissions or group.name == "Administrators":
                 continue
                 continue
             perms = list(group.permissions)
             perms = list(group.permissions)
             changed = False
             changed = False
-            if group.name == "Administrators":
-                for new_perm in ("pipelines:read", "pipelines:write", "pipelines:run"):
-                    if new_perm not in perms:
-                        perms.append(new_perm)
-                        changed = True
-                        logger.info("Added %s to Administrators group (backfill)", new_perm)
-            elif group.name == "Operators":
+            if group.name == "Operators":
                 for new_perm in ("pipelines:read", "pipelines:write", "pipelines:run"):
                 for new_perm in ("pipelines:read", "pipelines:write", "pipelines:run"):
                     if new_perm not in perms:
                     if new_perm not in perms:
                         perms.append(new_perm)
                         perms.append(new_perm)

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

@@ -730,6 +730,173 @@ class TestPipelineC:
         assert body["parent_run_id"] == parent.id
         assert body["parent_run_id"] == parent.id
 
 
 
 
+class TestPolishFollowUp:
+    """Polish-pass fixes: dashboard target filters, clear endpoint, and the
+    deleted-queue-entry → cancelled rollup behaviour."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_dashboard_filters_by_target_printer(
+        self,
+        async_client: AsyncClient,
+        pipeline_factory,
+        printer_factory,
+        library_file_factory,
+        db_session,
+    ):
+        from backend.app.models.pipeline_run import PipelineRun
+
+        printer_a = await printer_factory()
+        printer_b = await printer_factory()
+        pipe_a = await pipeline_factory(target_printer_id=printer_a.id)
+        pipe_b = await pipeline_factory(target_printer_id=printer_b.id)
+        src = await library_file_factory()
+        for pipe in (pipe_a, pipe_a, pipe_b):
+            db_session.add(
+                PipelineRun(
+                    pipeline_id=pipe["id"],
+                    source_library_file_id=src.id,
+                    copies=1,
+                    status="completed",
+                )
+            )
+        await db_session.commit()
+
+        resp = await async_client.get(f"/api/v1/pipeline-runs?target_printer_id={printer_a.id}")
+        assert resp.status_code == 200
+        body = resp.json()
+        assert body["total"] == 2
+        assert all(r["target_printer_id"] == printer_a.id for r in body["runs"])
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_dashboard_filters_by_target_model_class(
+        self,
+        async_client: AsyncClient,
+        pipeline_factory,
+        printer_factory,
+        library_file_factory,
+        db_session,
+    ):
+        from backend.app.models.pipeline_run import PipelineRun
+
+        await printer_factory(model="X1C")
+        await printer_factory(model="P1S")
+        # Two pipelines, one class-targeting X1C, one P1S.
+        pipe_x = await pipeline_factory()
+        await async_client.put(
+            f"/api/v1/slicer-pipelines/{pipe_x['id']}",
+            json={"target_kind": "printer_class", "target_printer_id": 0, "target_model_class": "X1C"},
+        )
+        pipe_p = await pipeline_factory()
+        await async_client.put(
+            f"/api/v1/slicer-pipelines/{pipe_p['id']}",
+            json={"target_kind": "printer_class", "target_printer_id": 0, "target_model_class": "P1S"},
+        )
+        src = await library_file_factory()
+        for pipe in (pipe_x, pipe_p, pipe_p):
+            db_session.add(
+                PipelineRun(
+                    pipeline_id=pipe["id"],
+                    source_library_file_id=src.id,
+                    copies=1,
+                    status="completed",
+                )
+            )
+        await db_session.commit()
+
+        resp = await async_client.get("/api/v1/pipeline-runs?target_model_class=P1S")
+        assert resp.status_code == 200
+        body = resp.json()
+        assert body["total"] == 2
+        assert all(r["target_model_class"] == "P1S" for r in body["runs"])
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_clear_endpoint_deletes_terminal_runs_only(
+        self,
+        async_client: AsyncClient,
+        pipeline_factory,
+        printer_factory,
+        library_file_factory,
+        db_session,
+    ):
+        from backend.app.models.pipeline_run import PipelineRun
+
+        printer = await printer_factory()
+        pipe = await pipeline_factory(target_printer_id=printer.id)
+        src = await library_file_factory()
+        for status in ("completed", "failed", "cancelled", "partial_failure", "dispatching", "in_progress"):
+            db_session.add(
+                PipelineRun(
+                    pipeline_id=pipe["id"],
+                    source_library_file_id=src.id,
+                    copies=1,
+                    status=status,
+                )
+            )
+        await db_session.commit()
+
+        resp = await async_client.post("/api/v1/pipeline-runs/clear")
+        assert resp.status_code == 200, resp.text
+        assert resp.json()["deleted"] == 4  # 4 terminal statuses cleared
+
+        # The in-flight rows survive.
+        survivors = (await async_client.get("/api/v1/pipeline-runs")).json()
+        assert survivors["total"] == 2
+        assert {r["status"] for r in survivors["runs"]} == {"dispatching", "in_progress"}
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_deleted_queue_entry_rolls_up_as_cancelled(
+        self,
+        async_client: AsyncClient,
+        pipeline_factory,
+        printer_factory,
+        library_file_factory,
+        db_session,
+    ):
+        """When the queue entry that a PipelineJob is linked to gets deleted
+        from the print-queue page, the job's live status should roll up to
+        ``cancelled`` so the run doesn't sit forever showing ``queued`` /
+        ``dispatching``."""
+        from backend.app.models.pipeline_run import PipelineJob, PipelineRun
+
+        printer = await printer_factory()
+        pipe = await pipeline_factory(target_printer_id=printer.id)
+        src = await library_file_factory()
+        # Simulate the state PR C leaves a successful dispatch in: run is
+        # 'dispatching' and the job has a queue_entry_id pointing at a
+        # PrintQueueItem that no longer exists.
+        run = PipelineRun(
+            pipeline_id=pipe["id"],
+            source_library_file_id=src.id,
+            copies=1,
+            status="dispatching",
+        )
+        db_session.add(run)
+        await db_session.flush()
+        db_session.add(
+            PipelineJob(
+                pipeline_run_id=run.id,
+                copy_index=0,
+                queue_entry_id=999999,  # Doesn't exist — simulates manual delete from queue.
+                assigned_printer_id=printer.id,
+                status="queued",
+            )
+        )
+        await db_session.commit()
+        await db_session.refresh(run)
+
+        resp = await async_client.get(f"/api/v1/pipeline-runs/{run.id}")
+        assert resp.status_code == 200, resp.text
+        body = resp.json()
+        # Job rolled up to cancelled because the queue entry is gone.
+        assert body["jobs"][0]["status"] == "cancelled"
+        # Run also rolls up — all jobs cancelled → run reads as cancelled.
+        assert body["status"] == "cancelled"
+
+
 class TestCancelTerminal:
 class TestCancelTerminal:
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration

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

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

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

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

+ 4 - 2
frontend/src/App.tsx

@@ -5,7 +5,6 @@ import { Layout } from './components/Layout';
 import { PrintersPage } from './pages/PrintersPage';
 import { PrintersPage } from './pages/PrintersPage';
 import { ArchivesPage } from './pages/ArchivesPage';
 import { ArchivesPage } from './pages/ArchivesPage';
 import { QueuePage } from './pages/QueuePage';
 import { QueuePage } from './pages/QueuePage';
-import { PipelineRunsPage } from './pages/PipelineRunsPage';
 import { StatsPage } from './pages/StatsPage';
 import { StatsPage } from './pages/StatsPage';
 import { SettingsPage } from './pages/SettingsPage';
 import { SettingsPage } from './pages/SettingsPage';
 import { ProfilesPage } from './pages/ProfilesPage';
 import { ProfilesPage } from './pages/ProfilesPage';
@@ -198,7 +197,10 @@ function App() {
                   <Route index element={<PrintersPage />} />
                   <Route index element={<PrintersPage />} />
                   <Route path="archives" element={<ArchivesPage />} />
                   <Route path="archives" element={<ArchivesPage />} />
                   <Route path="queue" element={<QueuePage />} />
                   <Route path="queue" element={<QueuePage />} />
-                  <Route path="pipelines/runs" element={<PermissionRoute permission="pipelines:read"><PipelineRunsPage /></PermissionRoute>} />
+                  {/* Slicer Pipelines (#1425) — Pipelines tab lives on the
+                      Print Queue page (Queue + History + Timeline +
+                      Pipelines). Old standalone URL redirects. */}
+                  <Route path="pipelines/runs" element={<Navigate to="/queue?tab=pipelines" replace />} />
                   <Route path="stats" element={<StatsPage />} />
                   <Route path="stats" element={<StatsPage />} />
                   <Route path="profiles" element={<ProfilesPage />} />
                   <Route path="profiles" element={<ProfilesPage />} />
                   <Route path="maintenance" element={<MaintenancePage />} />
                   <Route path="maintenance" element={<MaintenancePage />} />

+ 7 - 7
frontend/src/__tests__/pages/PipelineRunsPage.test.tsx

@@ -9,7 +9,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
 import { screen, waitFor } from '@testing-library/react';
 import { screen, waitFor } from '@testing-library/react';
 import userEvent from '@testing-library/user-event';
 import userEvent from '@testing-library/user-event';
 import { render } from '../utils';
 import { render } from '../utils';
-import { PipelineRunsPage } from '../../pages/PipelineRunsPage';
+import { PipelineRunsView } from '../../pages/PipelineRunsPage';
 import { api, type PipelineRun } from '../../api/client';
 import { api, type PipelineRun } from '../../api/client';
 
 
 vi.mock('../../api/client', () => ({
 vi.mock('../../api/client', () => ({
@@ -88,7 +88,7 @@ function makeRun(overrides: Partial<PipelineRun> = {}): PipelineRun {
   };
   };
 }
 }
 
 
-describe('PipelineRunsPage', () => {
+describe('PipelineRunsView', () => {
   beforeEach(() => {
   beforeEach(() => {
     vi.clearAllMocks();
     vi.clearAllMocks();
     mockApi.listSlicerPipelines.mockResolvedValue({ pipelines: [] });
     mockApi.listSlicerPipelines.mockResolvedValue({ pipelines: [] });
@@ -96,7 +96,7 @@ describe('PipelineRunsPage', () => {
   });
   });
 
 
   it('renders empty state when no runs exist', async () => {
   it('renders empty state when no runs exist', async () => {
-    render(<PipelineRunsPage />);
+    render(<PipelineRunsView />);
     await waitFor(() => {
     await waitFor(() => {
       expect(screen.getByText(/No pipeline runs yet/i)).toBeInTheDocument();
       expect(screen.getByText(/No pipeline runs yet/i)).toBeInTheDocument();
     });
     });
@@ -104,7 +104,7 @@ describe('PipelineRunsPage', () => {
 
 
   it('lists runs and surfaces the pipeline name + status chip', async () => {
   it('lists runs and surfaces the pipeline name + status chip', async () => {
     mockApi.listAllPipelineRuns.mockResolvedValue({ runs: [makeRun()], total: 1 });
     mockApi.listAllPipelineRuns.mockResolvedValue({ runs: [makeRun()], total: 1 });
-    render(<PipelineRunsPage />);
+    render(<PipelineRunsView />);
     await waitFor(() => {
     await waitFor(() => {
       expect(screen.getByText(/Production Batch/i)).toBeInTheDocument();
       expect(screen.getByText(/Production Batch/i)).toBeInTheDocument();
       // Status chip uses the i18n key.
       // Status chip uses the i18n key.
@@ -115,7 +115,7 @@ describe('PipelineRunsPage', () => {
   it('shows a Cancel button on in-flight runs and fires the mutation', async () => {
   it('shows a Cancel button on in-flight runs and fires the mutation', async () => {
     mockApi.listAllPipelineRuns.mockResolvedValue({ runs: [makeRun()], total: 1 });
     mockApi.listAllPipelineRuns.mockResolvedValue({ runs: [makeRun()], total: 1 });
     mockApi.cancelPipelineRun.mockResolvedValue(makeRun({ status: 'cancelled' }));
     mockApi.cancelPipelineRun.mockResolvedValue(makeRun({ status: 'cancelled' }));
-    render(<PipelineRunsPage />);
+    render(<PipelineRunsView />);
     const user = userEvent.setup();
     const user = userEvent.setup();
     await waitFor(() => expect(screen.getByText(/Production Batch/i)).toBeInTheDocument());
     await waitFor(() => expect(screen.getByText(/Production Batch/i)).toBeInTheDocument());
     await user.click(screen.getByRole('button', { name: /Cancel/i }));
     await user.click(screen.getByRole('button', { name: /Cancel/i }));
@@ -135,7 +135,7 @@ describe('PipelineRunsPage', () => {
       total: 1,
       total: 1,
     });
     });
     mockApi.retryFailedPipelineRun.mockResolvedValue(makeRun({ id: 2, parent_run_id: 1, copies: 2 }));
     mockApi.retryFailedPipelineRun.mockResolvedValue(makeRun({ id: 2, parent_run_id: 1, copies: 2 }));
-    render(<PipelineRunsPage />);
+    render(<PipelineRunsView />);
     const user = userEvent.setup();
     const user = userEvent.setup();
     await waitFor(() => expect(screen.getByText(/Production Batch/i)).toBeInTheDocument());
     await waitFor(() => expect(screen.getByText(/Production Batch/i)).toBeInTheDocument());
     await user.click(screen.getByRole('button', { name: /Retry failed/i }));
     await user.click(screen.getByRole('button', { name: /Retry failed/i }));
@@ -144,7 +144,7 @@ describe('PipelineRunsPage', () => {
 
 
   it('expands the row to show per-copy jobs', async () => {
   it('expands the row to show per-copy jobs', async () => {
     mockApi.listAllPipelineRuns.mockResolvedValue({ runs: [makeRun()], total: 1 });
     mockApi.listAllPipelineRuns.mockResolvedValue({ runs: [makeRun()], total: 1 });
-    render(<PipelineRunsPage />);
+    render(<PipelineRunsView />);
     const user = userEvent.setup();
     const user = userEvent.setup();
     await waitFor(() => expect(screen.getByText(/Production Batch/i)).toBeInTheDocument());
     await waitFor(() => expect(screen.getByText(/Production Batch/i)).toBeInTheDocument());
     await user.click(screen.getByRole('button', { name: /Expand/i }));
     await user.click(screen.getByRole('button', { name: /Expand/i }));

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

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

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

@@ -6390,17 +6390,26 @@ export const api = {
     offset?: number;
     offset?: number;
     pipelineId?: number;
     pipelineId?: number;
     status?: string;
     status?: string;
+    targetPrinterId?: number;
+    targetModelClass?: string;
   } = {}) => {
   } = {}) => {
     const search = new URLSearchParams();
     const search = new URLSearchParams();
     if (params.limit) search.set('limit', String(params.limit));
     if (params.limit) search.set('limit', String(params.limit));
     if (params.offset) search.set('offset', String(params.offset));
     if (params.offset) search.set('offset', String(params.offset));
     if (params.pipelineId) search.set('pipeline_id', String(params.pipelineId));
     if (params.pipelineId) search.set('pipeline_id', String(params.pipelineId));
     if (params.status) search.set('status', params.status);
     if (params.status) search.set('status', params.status);
+    if (params.targetPrinterId) search.set('target_printer_id', String(params.targetPrinterId));
+    if (params.targetModelClass) search.set('target_model_class', params.targetModelClass);
     const q = search.toString();
     const q = search.toString();
     return request<PipelineRunListResponse>(
     return request<PipelineRunListResponse>(
       `/pipeline-runs${q ? '?' + q : ''}`,
       `/pipeline-runs${q ? '?' + q : ''}`,
     );
     );
   },
   },
+  // Clear terminal pipeline runs (#1425 PR C polish). Deletes all runs in
+  // a terminal state (completed/failed/cancelled/partial_failure); in-flight
+  // runs are preserved.
+  clearTerminalPipelineRuns: () =>
+    request<{ deleted: number }>('/pipeline-runs/clear', { method: 'POST' }),
   getPipelineRun: (runId: number) =>
   getPipelineRun: (runId: number) =>
     request<PipelineRun>(`/pipeline-runs/${runId}`),
     request<PipelineRun>(`/pipeline-runs/${runId}`),
   cancelPipelineRun: (runId: number) =>
   cancelPipelineRun: (runId: number) =>

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

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

+ 241 - 56
frontend/src/components/SlicerPipelinesPanel.tsx

@@ -1,7 +1,7 @@
-import { useState } from 'react';
+import { useMemo, useState } from 'react';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
 import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
-import { AlertTriangle, Check, Edit2, Loader2, Printer as PrinterIcon, Trash2, Workflow, X } from 'lucide-react';
+import { AlertTriangle, Check, Edit2, Loader2, Printer as PrinterIcon, Search, Trash2, Workflow, X } from 'lucide-react';
 import {
 import {
   api,
   api,
   type PipelineRun,
   type PipelineRun,
@@ -62,14 +62,25 @@ export function SlicerPipelinesPanel() {
       description,
       description,
       target_printer_id,
       target_printer_id,
       target_kind,
       target_kind,
+      target_model_class,
+      fanout_strategy,
     }: {
     }: {
       id: number;
       id: number;
       name?: string;
       name?: string;
       description?: string | null;
       description?: string | null;
       target_printer_id?: number | null;
       target_printer_id?: number | null;
       target_kind?: 'specific_printer' | 'printer_class';
       target_kind?: 'specific_printer' | 'printer_class';
+      target_model_class?: string | null;
+      fanout_strategy?: 'max_parallel' | 'fill_one_first' | 'round_robin';
     }) =>
     }) =>
-      api.updateSlicerPipeline(id, { name, description, target_printer_id, target_kind }),
+      api.updateSlicerPipeline(id, {
+        name,
+        description,
+        target_printer_id,
+        target_kind,
+        target_model_class,
+        fanout_strategy,
+      }),
     onSuccess: () => {
     onSuccess: () => {
       queryClient.invalidateQueries({ queryKey: ['slicer-pipelines'] });
       queryClient.invalidateQueries({ queryKey: ['slicer-pipelines'] });
       showToast(t('settings.pipelines.toast.saved', 'Pipeline saved'), 'success');
       showToast(t('settings.pipelines.toast.saved', 'Pipeline saved'), 'success');
@@ -90,7 +101,62 @@ export function SlicerPipelinesPanel() {
     },
     },
   });
   });
 
 
-  const pipelines = list?.pipelines ?? [];
+  // Panel-level search + filter (#1425 PR C polish). Filters by pipeline name
+  // (case-insensitive substring) and by target — the dropdown lists every
+  // distinct target in use across the saved pipelines so operators can jump
+  // straight to "show me everything for X1C #2" or "everything for the H2D
+  // class". State is local — list is small enough that re-rendering on every
+  // keystroke is fine.
+  const [searchTerm, setSearchTerm] = useState('');
+  // Encoded target filter value: '' = all, 'none' = no target set,
+  // 'p:<printer_id>' = specific printer, 'c:<model_class>' = printer class.
+  const [targetFilter, setTargetFilter] = useState<string>('');
+
+  const allPipelines = useMemo(() => list?.pipelines ?? [], [list?.pipelines]);
+
+  // Build the dropdown's options from the targets actually in use. Only
+  // printers / classes that at least one pipeline points at appear — keeps
+  // the dropdown short and meaningful for installs with many printers but
+  // few pipelines.
+  const targetOptions = useMemo(() => {
+    const printerIds = new Set<number>();
+    const classes = new Set<string>();
+    let anyWithoutTarget = false;
+    for (const p of allPipelines) {
+      if (p.target_kind === 'printer_class' && p.target_model_class) {
+        classes.add(p.target_model_class);
+      } else if (p.target_printer_id) {
+        printerIds.add(p.target_printer_id);
+      } else {
+        anyWithoutTarget = true;
+      }
+    }
+    return {
+      printers: (printers ?? []).filter((pr) => printerIds.has(pr.id)),
+      classes: Array.from(classes).sort(),
+      anyWithoutTarget,
+    };
+  }, [allPipelines, printers]);
+
+  const pipelines = useMemo(() => {
+    const term = searchTerm.trim().toLowerCase();
+    return allPipelines.filter((p) => {
+      if (term && !p.name.toLowerCase().includes(term)) return false;
+      if (targetFilter === 'none') {
+        const hasTarget = p.target_kind === 'printer_class'
+          ? !!p.target_model_class
+          : p.target_printer_id !== null;
+        if (hasTarget) return false;
+      } else if (targetFilter.startsWith('p:')) {
+        const wantId = parseInt(targetFilter.slice(2), 10);
+        if (p.target_kind === 'printer_class' || p.target_printer_id !== wantId) return false;
+      } else if (targetFilter.startsWith('c:')) {
+        const wantClass = targetFilter.slice(2);
+        if (p.target_kind !== 'printer_class' || p.target_model_class !== wantClass) return false;
+      }
+      return true;
+    });
+  }, [allPipelines, searchTerm, targetFilter]);
 
 
   return (
   return (
     <Card>
     <Card>
@@ -118,7 +184,65 @@ export function SlicerPipelinesPanel() {
             {t('settings.pipelines.loadError', 'Could not load pipelines.')}
             {t('settings.pipelines.loadError', 'Could not load pipelines.')}
           </div>
           </div>
         )}
         )}
-        {!isLoading && !error && pipelines.length === 0 && (
+        {/* Search + target-type filter. Only render when there are pipelines
+            to filter; the empty-state hint reads better without controls. */}
+        {!isLoading && !error && allPipelines.length > 0 && (
+          <div className="flex flex-wrap items-center gap-2 mb-3">
+            <div className="relative flex-1 min-w-[12rem]">
+              <Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-bambu-gray pointer-events-none" />
+              <input
+                type="search"
+                value={searchTerm}
+                onChange={(e) => setSearchTerm(e.target.value)}
+                placeholder={t('settings.pipelines.searchPlaceholder', 'Search pipelines…')}
+                aria-label={t('settings.pipelines.searchPlaceholder', 'Search pipelines…')}
+                className="w-full pl-7 pr-2 py-1 text-xs bg-bambu-dark border border-bambu-dark-tertiary rounded text-white"
+              />
+            </div>
+            <select
+              value={targetFilter}
+              onChange={(e) => setTargetFilter(e.target.value)}
+              aria-label={t('settings.pipelines.filterTarget', 'Filter by target')}
+              className="text-xs px-2 py-1 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white"
+            >
+              <option value="">
+                {t('settings.pipelines.filter.all', 'All targets')}
+              </option>
+              {targetOptions.printers.length > 0 && (
+                <optgroup label={t('settings.pipelines.field.targetKindSpecific', 'Specific printer')}>
+                  {targetOptions.printers.map((p) => (
+                    <option key={`p-${p.id}`} value={`p:${p.id}`}>
+                      {p.name}
+                    </option>
+                  ))}
+                </optgroup>
+              )}
+              {targetOptions.classes.length > 0 && (
+                <optgroup label={t('settings.pipelines.field.targetKindClass', 'Printer class')}>
+                  {targetOptions.classes.map((c) => (
+                    <option key={`c-${c}`} value={`c:${c}`}>
+                      {t('library.runWithPipeline.classTarget', 'Any {{model}}', { model: c })}
+                    </option>
+                  ))}
+                </optgroup>
+              )}
+              {targetOptions.anyWithoutTarget && (
+                <option value="none">
+                  {t('settings.pipelines.filter.noTarget', 'No target set')}
+                </option>
+              )}
+            </select>
+            {(searchTerm || targetFilter) && (
+              <span className="text-xs text-bambu-gray">
+                {t('settings.pipelines.filter.count', '{{shown}} / {{total}}', {
+                  shown: pipelines.length,
+                  total: allPipelines.length,
+                })}
+              </span>
+            )}
+          </div>
+        )}
+        {!isLoading && !error && allPipelines.length === 0 && (
           <div className="text-sm text-bambu-gray space-y-2">
           <div className="text-sm text-bambu-gray space-y-2">
             <p>{t('settings.pipelines.empty.title', 'No pipelines yet.')}</p>
             <p>{t('settings.pipelines.empty.title', 'No pipelines yet.')}</p>
             <p>
             <p>
@@ -129,6 +253,11 @@ export function SlicerPipelinesPanel() {
             </p>
             </p>
           </div>
           </div>
         )}
         )}
+        {!isLoading && !error && allPipelines.length > 0 && pipelines.length === 0 && (
+          <p className="text-sm text-bambu-gray">
+            {t('settings.pipelines.filter.noMatches', 'No pipelines match the current filters.')}
+          </p>
+        )}
         {!isLoading && !error && pipelines.length > 0 && (
         {!isLoading && !error && pipelines.length > 0 && (
           <div className="space-y-2">
           <div className="space-y-2">
             {pipelines.map((p) => (
             {pipelines.map((p) => (
@@ -215,6 +344,19 @@ function PipelineRow({
   const printerName = resolveName(presets, 'printer', pipeline.printer_preset);
   const printerName = resolveName(presets, 'printer', pipeline.printer_preset);
   const processName = resolveName(presets, 'process', pipeline.process_preset);
   const processName = resolveName(presets, 'process', pipeline.process_preset);
   const filamentResolutions = pipeline.filament_presets.map((f) => resolveName(presets, 'filament', f));
   const filamentResolutions = pipeline.filament_presets.map((f) => resolveName(presets, 'filament', f));
+  // Collapse identical filaments into a single "All N slots" line — most
+  // production pipelines load the same filament into every AMS slot, and
+  // listing the same line three times is just noise. Compares raw preset
+  // refs (source + id) rather than resolved names so the dedup is correct
+  // even when ``presets`` hasn't loaded yet.
+  const filamentsAllIdentical =
+    pipeline.filament_presets.length > 1 &&
+    pipeline.filament_presets.every(
+      (f) =>
+        f.source === pipeline.filament_presets[0].source &&
+        f.id === pipeline.filament_presets[0].id,
+    );
+
   const hasStaleRef =
   const hasStaleRef =
     presets !== undefined &&
     presets !== undefined &&
     (printerName === null || processName === null || filamentResolutions.some((n) => n === null));
     (printerName === null || processName === null || filamentResolutions.some((n) => n === null));
@@ -381,7 +523,42 @@ function PipelineRow({
             </div>
             </div>
           ) : (
           ) : (
             <>
             <>
-              <h4 className="text-sm font-medium text-white truncate">{pipeline.name}</h4>
+              {/* Header: name + inline target chip (PR C polish). The target
+                  context — specific printer name OR class+strategy — is the
+                  thing the operator most needs to read at a glance, so it
+                  rides up here next to the title instead of buried below. */}
+              <div className="flex flex-wrap items-baseline gap-x-2 gap-y-1">
+                <h4 className="text-sm font-medium text-white truncate">{pipeline.name}</h4>
+                <span
+                  className={`text-xs px-1.5 py-0.5 rounded inline-flex items-center gap-1 ${
+                    needsTarget
+                      ? 'bg-amber-500/15 text-amber-400'
+                      : 'bg-bambu-dark-tertiary text-bambu-gray'
+                  }`}
+                >
+                  <PrinterIcon className="w-3 h-3" />
+                  {needsTarget ? (
+                    t('settings.pipelines.noTargetHint', 'Set a target printer to run this')
+                  ) : isClassTargeting ? (
+                    <>
+                      {t('library.runWithPipeline.classTarget', 'Any {{model}}', {
+                        model: pipeline.target_model_class,
+                      })}
+                      {pipeline.fanout_strategy && (
+                        <span className="text-bambu-gray/60">
+                          {' · '}
+                          {t(
+                            `settings.pipelines.field.fanoutShort.${pipeline.fanout_strategy}`,
+                            pipeline.fanout_strategy,
+                          )}
+                        </span>
+                      )}
+                    </>
+                  ) : (
+                    targetPrinter?.name ?? ''
+                  )}
+                </span>
+              </div>
               {pipeline.description && (
               {pipeline.description && (
                 <p className="text-xs text-bambu-gray mt-0.5">{pipeline.description}</p>
                 <p className="text-xs text-bambu-gray mt-0.5">{pipeline.description}</p>
               )}
               )}
@@ -430,60 +607,68 @@ function PipelineRow({
       </div>
       </div>
 
 
       {!editing && (
       {!editing && (
-        <div className="mt-2 grid grid-cols-1 sm:grid-cols-2 gap-x-3 gap-y-1 text-xs">
-          <PresetLine
-            label={t('settings.pipelines.slot.printer', 'Printer')}
-            ref={pipeline.printer_preset}
-            name={printerName}
-          />
-          <PresetLine
-            label={t('settings.pipelines.slot.process', 'Process')}
-            ref={pipeline.process_preset}
-            name={processName}
-          />
-          {pipeline.filament_presets.map((f, i) => (
+        <div className="mt-2 grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-2 text-xs">
+          {/* Profiles group — printer / process / bed. These travel together
+              because they describe the slicer profile bundle that produces a
+              single gcode. The full preset name (including the BambuStudio
+              ``@BBL <model>`` suffix) is shown verbatim so the user can match
+              it 1:1 against what they see in the slicer. */}
+          <div className="space-y-0.5">
+            <div className="text-[10px] uppercase tracking-wide text-bambu-gray/60">
+              {t('settings.pipelines.group.profiles', 'Profiles')}
+            </div>
             <PresetLine
             <PresetLine
-              key={i}
-              label={
-                pipeline.filament_presets.length > 1
-                  ? t('settings.pipelines.slot.filamentN', 'Filament {{n}}', { n: i + 1 })
-                  : t('settings.pipelines.slot.filament', 'Filament')
-              }
-              ref={f}
-              name={filamentResolutions[i]}
+              label={t('settings.pipelines.slot.printer', 'Printer')}
+              ref={pipeline.printer_preset}
+              name={printerName}
             />
             />
-          ))}
-          {pipeline.bed_type && (
-            <div className="text-bambu-gray">
-              <span className="font-medium text-bambu-gray/80">
-                {t('settings.pipelines.slot.bed', 'Bed')}:
-              </span>{' '}
-              <span className="text-white">{pipeline.bed_type}</span>
+            <PresetLine
+              label={t('settings.pipelines.slot.process', 'Process')}
+              ref={pipeline.process_preset}
+              name={processName}
+            />
+            {pipeline.bed_type && (
+              <div className="text-bambu-gray">
+                <span className="font-medium text-bambu-gray/80">
+                  {t('settings.pipelines.slot.bed', 'Bed')}:
+                </span>{' '}
+                <span className="text-white">{pipeline.bed_type}</span>
+              </div>
+            )}
+          </div>
+          {/* Filaments group — one per AMS slot. When every slot is the same
+              filament (the common single-color production-batch case) we
+              collapse them into a single ``All 4 slots: PLA Basic`` line. */}
+          <div className="space-y-0.5">
+            <div className="text-[10px] uppercase tracking-wide text-bambu-gray/60">
+              {t('settings.pipelines.group.filaments', 'Filaments')}
+              {pipeline.filament_presets.length > 1 && (
+                <span className="text-bambu-gray/60 normal-case ml-1">
+                  ({pipeline.filament_presets.length})
+                </span>
+              )}
             </div>
             </div>
-          )}
-          <div className="text-bambu-gray flex items-center gap-1">
-            <PrinterIcon className="w-3 h-3" />
-            <span className="font-medium text-bambu-gray/80">
-              {isClassTargeting
-                ? t('settings.pipelines.field.targetModelClass', 'Printer model')
-                : t('settings.pipelines.field.targetPrinter', 'Target printer')}
-              :
-            </span>{' '}
-            {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>
+            {filamentsAllIdentical ? (
+              <PresetLine
+                label={t('settings.pipelines.slot.filamentAll', 'All {{n}} slots', {
+                  n: pipeline.filament_presets.length,
+                })}
+                ref={pipeline.filament_presets[0]}
+                name={filamentResolutions[0]}
+              />
             ) : (
             ) : (
-              <span className="text-amber-400">
-                {t('settings.pipelines.noTargetHint', 'Set a target printer to run this')}
-              </span>
+              pipeline.filament_presets.map((f, i) => (
+                <PresetLine
+                  key={i}
+                  label={
+                    pipeline.filament_presets.length > 1
+                      ? t('settings.pipelines.slot.filamentN', 'Filament {{n}}', { n: i + 1 })
+                      : t('settings.pipelines.slot.filament', 'Filament')
+                  }
+                  ref={f}
+                  name={filamentResolutions[i]}
+                />
+              ))
             )}
             )}
           </div>
           </div>
         </div>
         </div>

+ 35 - 1
frontend/src/i18n/locales/de.ts

@@ -4,7 +4,6 @@ export default {
     printers: 'Drucker',
     printers: 'Drucker',
     archives: 'Archiv',
     archives: 'Archiv',
     queue: 'Druckwarteschlange',
     queue: 'Druckwarteschlange',
-    pipelineRuns: 'Pipeline-Läufe',
     stats: 'Statistiken',
     stats: 'Statistiken',
     profiles: 'Profile',
     profiles: 'Profile',
     maintenance: 'Wartung',
     maintenance: 'Wartung',
@@ -1055,8 +1054,16 @@ export default {
     filter: {
     filter: {
       pipeline: 'Pipeline',
       pipeline: 'Pipeline',
       status: 'Status',
       status: 'Status',
+      target: 'Ziel',
       all: 'Alle',
       all: 'Alle',
+      allPipelines: 'Alle Pipelines',
+      allStatus: 'Alle Status',
+      allTargets: 'Alle Ziele',
+      clear: 'Filter zurücksetzen',
+      noMatches: 'Keine Läufe entsprechen den aktuellen Filtern.',
     },
     },
+    totalCount_one: '{{n}} Lauf',
+    totalCount_other: '{{n}} Läufe',
     copies: '{{n}} Kopien',
     copies: '{{n}} Kopien',
     failedCount: '{{n}} fehlgeschlagen',
     failedCount: '{{n}} fehlgeschlagen',
     copyN: 'Kopie {{n}}',
     copyN: 'Kopie {{n}}',
@@ -1068,7 +1075,13 @@ export default {
       cancelFailed: 'Abbruch fehlgeschlagen',
       cancelFailed: 'Abbruch fehlgeschlagen',
       retryStarted: 'Wiederholung gestartet',
       retryStarted: 'Wiederholung gestartet',
       retryFailed: 'Wiederholung fehlgeschlagen',
       retryFailed: 'Wiederholung fehlgeschlagen',
+      cleared: '{{n}} Läufe gelöscht',
+      clearFailed: 'Löschen fehlgeschlagen',
     },
     },
+    clearLog: 'Verlauf löschen',
+    clearConfirmTitle: 'Verlauf löschen?',
+    clearConfirmBody: 'Jeden abgeschlossenen, fehlgeschlagenen, abgebrochenen und teilweise fehlgeschlagenen Pipeline-Lauf löschen? Laufende Läufe bleiben erhalten. Dies kann nicht rückgängig gemacht werden.',
+    clearConfirmAction: 'Löschen',
     jobStatus: {
     jobStatus: {
       pending: 'ausstehend',
       pending: 'ausstehend',
       awaiting_printer: 'wartet auf Drucker',
       awaiting_printer: 'wartet auf Drucker',
@@ -1078,6 +1091,7 @@ export default {
       failed: 'fehlgeschlagen',
       failed: 'fehlgeschlagen',
       cancelled: 'abgebrochen',
       cancelled: 'abgebrochen',
     },
     },
+    cancelledByUser: 'Vom Benutzer abgebrochen',
   },
   },
 
 
   // Queue page
   // Queue page
@@ -1159,6 +1173,7 @@ export default {
       queue: 'Warteschlange',
       queue: 'Warteschlange',
       history: 'Verlauf',
       history: 'Verlauf',
       timeline: 'Zeitachse',
       timeline: 'Zeitachse',
+      pipelines: 'Druckabläufe',
     },
     },
     layout: {
     layout: {
       flatList: 'Liste',
       flatList: 'Liste',
@@ -2624,6 +2639,11 @@ export default {
           round_robin: 'Reihum — durch geeignete Drucker rotieren',
           round_robin: 'Reihum — durch geeignete Drucker rotieren',
           fill_one_first: 'Erst einen füllen — alle Kopien an einen Drucker binden',
           fill_one_first: 'Erst einen füllen — alle Kopien an einen Drucker binden',
         },
         },
+        fanoutShort: {
+          max_parallel: 'parallel',
+          round_robin: 'Reihum',
+          fill_one_first: 'einer zuerst',
+        },
       },
       },
       action: {
       action: {
         save: 'Speichern',
         save: 'Speichern',
@@ -2636,8 +2656,22 @@ export default {
         process: 'Prozess',
         process: 'Prozess',
         filament: 'Filament',
         filament: 'Filament',
         filamentN: 'Filament {{n}}',
         filamentN: 'Filament {{n}}',
+        filamentAll: 'Alle {{n}} Slots',
         bed: 'Druckplatte',
         bed: 'Druckplatte',
       },
       },
+      group: {
+        profiles: 'Profile',
+        filaments: 'Filamente',
+      },
+      searchPlaceholder: 'Pipelines durchsuchen…',
+      filterTargetType: 'Nach Zielart filtern',
+      filterTarget: 'Nach Ziel filtern',
+      filter: {
+        all: 'Alle Ziele',
+        noTarget: 'Kein Ziel festgelegt',
+        count: '{{shown}} / {{total}}',
+        noMatches: 'Keine Pipelines entsprechen den aktuellen Filtern.',
+      },
       toast: {
       toast: {
         saved: 'Pipeline gespeichert',
         saved: 'Pipeline gespeichert',
         saveFailed: 'Speichern fehlgeschlagen',
         saveFailed: 'Speichern fehlgeschlagen',

+ 41 - 2
frontend/src/i18n/locales/en.ts

@@ -4,7 +4,6 @@ export default {
     printers: 'Printers',
     printers: 'Printers',
     archives: 'Archives',
     archives: 'Archives',
     queue: 'Print Queue',
     queue: 'Print Queue',
-    pipelineRuns: 'Pipeline Runs',
     stats: 'Statistics',
     stats: 'Statistics',
     profiles: 'Profiles',
     profiles: 'Profiles',
     maintenance: 'Maintenance',
     maintenance: 'Maintenance',
@@ -1064,20 +1063,35 @@ export default {
     filter: {
     filter: {
       pipeline: 'Pipeline',
       pipeline: 'Pipeline',
       status: 'Status',
       status: 'Status',
+      target: 'Target',
       all: 'All',
       all: 'All',
-    },
+      allPipelines: 'All pipelines',
+      allStatus: 'All statuses',
+      allTargets: 'All targets',
+      clear: 'Clear filters',
+      noMatches: 'No runs match the current filters.',
+    },
+    totalCount_one: '{{n}} run',
+    totalCount_other: '{{n}} runs',
     copies: '{{n}} copies',
     copies: '{{n}} copies',
     failedCount: '{{n}} failed',
     failedCount: '{{n}} failed',
     copyN: 'Copy {{n}}',
     copyN: 'Copy {{n}}',
     retryFailed: 'Retry failed',
     retryFailed: 'Retry failed',
     retryOf: 'retry of #{{n}}',
     retryOf: 'retry of #{{n}}',
     pagination: '{{start}}–{{end}} of {{total}}',
     pagination: '{{start}}–{{end}} of {{total}}',
+    cancelledByUser: 'Cancelled by user',
     toast: {
     toast: {
       cancelled: 'Run cancelled',
       cancelled: 'Run cancelled',
       cancelFailed: 'Cancel failed',
       cancelFailed: 'Cancel failed',
       retryStarted: 'Retry started',
       retryStarted: 'Retry started',
       retryFailed: 'Retry failed',
       retryFailed: 'Retry failed',
+      cleared: '{{n}} runs cleared',
+      clearFailed: 'Clear failed',
     },
     },
+    clearLog: 'Clear log',
+    clearConfirmTitle: 'Clear log?',
+    clearConfirmBody: 'Delete every completed, failed, cancelled, and partial-failure pipeline run? In-flight runs are kept. This cannot be undone.',
+    clearConfirmAction: 'Clear',
     jobStatus: {
     jobStatus: {
       pending: 'pending',
       pending: 'pending',
       awaiting_printer: 'awaiting printer',
       awaiting_printer: 'awaiting printer',
@@ -1169,6 +1183,7 @@ export default {
       queue: 'Queue',
       queue: 'Queue',
       history: 'History',
       history: 'History',
       timeline: 'Timeline',
       timeline: 'Timeline',
+      pipelines: 'Pipelines',
     },
     },
     // Layout toggle on the Queue tab — distinct from the sort dropdown
     // Layout toggle on the Queue tab — distinct from the sort dropdown
     // (those control order; these control whether items render as one flat
     // (those control order; these control whether items render as one flat
@@ -2647,6 +2662,14 @@ export default {
           round_robin: 'Round robin — cycle through eligible printers',
           round_robin: 'Round robin — cycle through eligible printers',
           fill_one_first: 'Fill one first — pin all copies to one printer',
           fill_one_first: 'Fill one first — pin all copies to one printer',
         },
         },
+        // Short labels for the inline target chip on each pipeline card.
+        // The verbose ones above explain the strategy in the editor; the
+        // card just needs a compact reminder of which one is in use.
+        fanoutShort: {
+          max_parallel: 'parallel',
+          round_robin: 'round robin',
+          fill_one_first: 'fill one first',
+        },
       },
       },
       action: {
       action: {
         save: 'Save',
         save: 'Save',
@@ -2659,8 +2682,24 @@ export default {
         process: 'Process',
         process: 'Process',
         filament: 'Filament',
         filament: 'Filament',
         filamentN: 'Filament {{n}}',
         filamentN: 'Filament {{n}}',
+        filamentAll: 'All {{n}} slots',
         bed: 'Bed',
         bed: 'Bed',
       },
       },
+      // PR C polish — grouped sections in the card body, plus the
+      // panel-level search + filter row.
+      group: {
+        profiles: 'Profiles',
+        filaments: 'Filaments',
+      },
+      searchPlaceholder: 'Search pipelines…',
+      filterTargetType: 'Filter by target type',
+      filterTarget: 'Filter by target',
+      filter: {
+        all: 'All targets',
+        noTarget: 'No target set',
+        count: '{{shown}} / {{total}}',
+        noMatches: 'No pipelines match the current filters.',
+      },
       toast: {
       toast: {
         saved: 'Pipeline saved',
         saved: 'Pipeline saved',
         saveFailed: 'Save failed',
         saveFailed: 'Save failed',

+ 35 - 1
frontend/src/i18n/locales/es.ts

@@ -4,7 +4,6 @@ export default {
     printers: 'Impresoras',
     printers: 'Impresoras',
     archives: 'Archivos',
     archives: 'Archivos',
     queue: 'Cola de impresión',
     queue: 'Cola de impresión',
-    pipelineRuns: 'Ejecuciones de pipeline',
     stats: 'Estadísticas',
     stats: 'Estadísticas',
     profiles: 'Perfiles',
     profiles: 'Perfiles',
     maintenance: 'Mantenimiento',
     maintenance: 'Mantenimiento',
@@ -1055,8 +1054,16 @@ export default {
     filter: {
     filter: {
       pipeline: 'Pipeline',
       pipeline: 'Pipeline',
       status: 'Estado',
       status: 'Estado',
+      target: 'Destino',
       all: 'Todas',
       all: 'Todas',
+      allPipelines: 'Todas las pipelines',
+      allStatus: 'Todos los estados',
+      allTargets: 'Todos los destinos',
+      clear: 'Limpiar filtros',
+      noMatches: 'Ninguna ejecución coincide con los filtros actuales.',
     },
     },
+    totalCount_one: '{{n}} ejecución',
+    totalCount_other: '{{n}} ejecuciones',
     copies: '{{n}} copias',
     copies: '{{n}} copias',
     failedCount: '{{n}} fallidas',
     failedCount: '{{n}} fallidas',
     copyN: 'Copia {{n}}',
     copyN: 'Copia {{n}}',
@@ -1068,7 +1075,13 @@ export default {
       cancelFailed: 'Cancelación fallida',
       cancelFailed: 'Cancelación fallida',
       retryStarted: 'Reintento iniciado',
       retryStarted: 'Reintento iniciado',
       retryFailed: 'Reintento fallido',
       retryFailed: 'Reintento fallido',
+      cleared: '{{n}} ejecuciones eliminadas',
+      clearFailed: 'Error al borrar',
     },
     },
+    clearLog: 'Borrar historial',
+    clearConfirmTitle: '¿Borrar historial?',
+    clearConfirmBody: '¿Eliminar todas las ejecuciones de pipeline completadas, fallidas, canceladas y con fallos parciales? Las ejecuciones en curso se conservan. Esto no se puede deshacer.',
+    clearConfirmAction: 'Borrar',
     jobStatus: {
     jobStatus: {
       pending: 'pendiente',
       pending: 'pendiente',
       awaiting_printer: 'esperando impresora',
       awaiting_printer: 'esperando impresora',
@@ -1078,6 +1091,7 @@ export default {
       failed: 'fallida',
       failed: 'fallida',
       cancelled: 'cancelada',
       cancelled: 'cancelada',
     },
     },
+    cancelledByUser: 'Cancelado por el usuario',
   },
   },
 
 
   // Queue page
   // Queue page
@@ -1159,6 +1173,7 @@ export default {
       queue: 'Cola',
       queue: 'Cola',
       history: 'Historial',
       history: 'Historial',
       timeline: 'Cronología',
       timeline: 'Cronología',
+      pipelines: 'Procesos',
     },
     },
     layout: {
     layout: {
       flatList: 'Lista',
       flatList: 'Lista',
@@ -2627,6 +2642,11 @@ export default {
           round_robin: 'Round robin — alternar entre impresoras elegibles',
           round_robin: 'Round robin — alternar entre impresoras elegibles',
           fill_one_first: 'Llenar una primero — fijar todas las copias a una impresora',
           fill_one_first: 'Llenar una primero — fijar todas las copias a una impresora',
         },
         },
+        fanoutShort: {
+          max_parallel: 'paralelo',
+          round_robin: 'round robin',
+          fill_one_first: 'primero uno',
+        },
       },
       },
       action: {
       action: {
         save: 'Guardar',
         save: 'Guardar',
@@ -2639,8 +2659,22 @@ export default {
         process: 'Proceso',
         process: 'Proceso',
         filament: 'Filamento',
         filament: 'Filamento',
         filamentN: 'Filamento {{n}}',
         filamentN: 'Filamento {{n}}',
+        filamentAll: 'Todos los {{n}} slots',
         bed: 'Placa',
         bed: 'Placa',
       },
       },
+      group: {
+        profiles: 'Perfiles',
+        filaments: 'Filamentos',
+      },
+      searchPlaceholder: 'Buscar pipelines…',
+      filterTargetType: 'Filtrar por tipo de destino',
+      filterTarget: 'Filtrar por destino',
+      filter: {
+        all: 'Todos los destinos',
+        noTarget: 'Sin destino',
+        count: '{{shown}} / {{total}}',
+        noMatches: 'Ninguna pipeline coincide con los filtros actuales.',
+      },
       toast: {
       toast: {
         saved: 'Pipeline guardada',
         saved: 'Pipeline guardada',
         saveFailed: 'Error al guardar',
         saveFailed: 'Error al guardar',

+ 35 - 1
frontend/src/i18n/locales/fr.ts

@@ -4,7 +4,6 @@ export default {
     printers: 'Imprimantes',
     printers: 'Imprimantes',
     archives: 'Archives',
     archives: 'Archives',
     queue: 'File d\'attente d\'impression',
     queue: 'File d\'attente d\'impression',
-    pipelineRuns: 'Exécutions de pipeline',
     stats: 'Statistiques',
     stats: 'Statistiques',
     profiles: 'Profils',
     profiles: 'Profils',
     maintenance: 'Maintenance',
     maintenance: 'Maintenance',
@@ -1055,8 +1054,16 @@ export default {
     filter: {
     filter: {
       pipeline: 'Pipeline',
       pipeline: 'Pipeline',
       status: 'Statut',
       status: 'Statut',
+      target: 'Cible',
       all: 'Toutes',
       all: 'Toutes',
+      allPipelines: 'Tous les pipelines',
+      allStatus: 'Tous les statuts',
+      allTargets: 'Toutes les cibles',
+      clear: 'Effacer les filtres',
+      noMatches: 'Aucune exécution ne correspond aux filtres actuels.',
     },
     },
+    totalCount_one: '{{n}} exécution',
+    totalCount_other: '{{n}} exécutions',
     copies: '{{n}} copies',
     copies: '{{n}} copies',
     failedCount: '{{n}} échouées',
     failedCount: '{{n}} échouées',
     copyN: 'Copie {{n}}',
     copyN: 'Copie {{n}}',
@@ -1068,7 +1075,13 @@ export default {
       cancelFailed: 'Annulation échouée',
       cancelFailed: 'Annulation échouée',
       retryStarted: 'Réessai démarré',
       retryStarted: 'Réessai démarré',
       retryFailed: 'Réessai échoué',
       retryFailed: 'Réessai échoué',
+      cleared: '{{n}} exécutions effacées',
+      clearFailed: 'Échec de l\'effacement',
     },
     },
+    clearLog: 'Effacer le journal',
+    clearConfirmTitle: 'Effacer le journal ?',
+    clearConfirmBody: 'Supprimer toutes les exécutions de pipeline terminées, échouées, annulées et en échec partiel ? Les exécutions en cours sont conservées. Ceci ne peut pas être annulé.',
+    clearConfirmAction: 'Effacer',
     jobStatus: {
     jobStatus: {
       pending: 'en attente',
       pending: 'en attente',
       awaiting_printer: 'attente imprimante',
       awaiting_printer: 'attente imprimante',
@@ -1078,6 +1091,7 @@ export default {
       failed: 'échouée',
       failed: 'échouée',
       cancelled: 'annulée',
       cancelled: 'annulée',
     },
     },
+    cancelledByUser: 'Annulé par l\'utilisateur',
   },
   },
 
 
   // Queue page
   // Queue page
@@ -1159,6 +1173,7 @@ export default {
       queue: 'File',
       queue: 'File',
       history: 'Historique',
       history: 'Historique',
       timeline: 'Chronologie',
       timeline: 'Chronologie',
+      pipelines: 'Exécutions',
     },
     },
     layout: {
     layout: {
       flatList: 'Liste',
       flatList: 'Liste',
@@ -2613,6 +2628,11 @@ export default {
           round_robin: 'Round robin — alterner entre imprimantes éligibles',
           round_robin: 'Round robin — alterner entre imprimantes éligibles',
           fill_one_first: 'Remplir une d\'abord — épingler toutes les copies à une imprimante',
           fill_one_first: 'Remplir une d\'abord — épingler toutes les copies à une imprimante',
         },
         },
+        fanoutShort: {
+          max_parallel: 'parallèle',
+          round_robin: 'round robin',
+          fill_one_first: 'remplir une',
+        },
       },
       },
       action: {
       action: {
         save: 'Enregistrer',
         save: 'Enregistrer',
@@ -2625,8 +2645,22 @@ export default {
         process: 'Processus',
         process: 'Processus',
         filament: 'Filament',
         filament: 'Filament',
         filamentN: 'Filament {{n}}',
         filamentN: 'Filament {{n}}',
+        filamentAll: 'Tous les {{n}} emplacements',
         bed: 'Plateau',
         bed: 'Plateau',
       },
       },
+      group: {
+        profiles: 'Profils',
+        filaments: 'Filaments',
+      },
+      searchPlaceholder: 'Rechercher des pipelines…',
+      filterTargetType: 'Filtrer par type de cible',
+      filterTarget: 'Filtrer par cible',
+      filter: {
+        all: 'Toutes les cibles',
+        noTarget: 'Aucune cible',
+        count: '{{shown}} / {{total}}',
+        noMatches: 'Aucun pipeline ne correspond aux filtres actuels.',
+      },
       toast: {
       toast: {
         saved: 'Pipeline enregistré',
         saved: 'Pipeline enregistré',
         saveFailed: 'Échec de l\'enregistrement',
         saveFailed: 'Échec de l\'enregistrement',

+ 35 - 1
frontend/src/i18n/locales/it.ts

@@ -4,7 +4,6 @@ export default {
     printers: 'Stampanti',
     printers: 'Stampanti',
     archives: 'Archivi',
     archives: 'Archivi',
     queue: 'Coda di stampa',
     queue: 'Coda di stampa',
-    pipelineRuns: 'Esecuzioni pipeline',
     stats: 'Statistiche',
     stats: 'Statistiche',
     profiles: 'Profili',
     profiles: 'Profili',
     maintenance: 'Manutenzione',
     maintenance: 'Manutenzione',
@@ -1055,8 +1054,16 @@ export default {
     filter: {
     filter: {
       pipeline: 'Pipeline',
       pipeline: 'Pipeline',
       status: 'Stato',
       status: 'Stato',
+      target: 'Destinazione',
       all: 'Tutte',
       all: 'Tutte',
+      allPipelines: 'Tutte le pipeline',
+      allStatus: 'Tutti gli stati',
+      allTargets: 'Tutte le destinazioni',
+      clear: 'Pulisci filtri',
+      noMatches: 'Nessuna esecuzione corrisponde ai filtri attuali.',
     },
     },
+    totalCount_one: '{{n}} esecuzione',
+    totalCount_other: '{{n}} esecuzioni',
     copies: '{{n}} copie',
     copies: '{{n}} copie',
     failedCount: '{{n}} fallite',
     failedCount: '{{n}} fallite',
     copyN: 'Copia {{n}}',
     copyN: 'Copia {{n}}',
@@ -1068,7 +1075,13 @@ export default {
       cancelFailed: 'Annullamento fallito',
       cancelFailed: 'Annullamento fallito',
       retryStarted: 'Ritentativo avviato',
       retryStarted: 'Ritentativo avviato',
       retryFailed: 'Ritentativo fallito',
       retryFailed: 'Ritentativo fallito',
+      cleared: '{{n}} esecuzioni eliminate',
+      clearFailed: 'Eliminazione fallita',
     },
     },
+    clearLog: 'Cancella cronologia',
+    clearConfirmTitle: 'Cancellare la cronologia?',
+    clearConfirmBody: 'Eliminare tutte le esecuzioni di pipeline completate, fallite, annullate e con fallimento parziale? Le esecuzioni in corso sono conservate. Questa operazione non può essere annullata.',
+    clearConfirmAction: 'Cancella',
     jobStatus: {
     jobStatus: {
       pending: 'in attesa',
       pending: 'in attesa',
       awaiting_printer: 'in attesa stampante',
       awaiting_printer: 'in attesa stampante',
@@ -1078,6 +1091,7 @@ export default {
       failed: 'fallita',
       failed: 'fallita',
       cancelled: 'annullata',
       cancelled: 'annullata',
     },
     },
+    cancelledByUser: 'Annullato dall\'utente',
   },
   },
 
 
   // Queue page
   // Queue page
@@ -1159,6 +1173,7 @@ export default {
       queue: 'Coda',
       queue: 'Coda',
       history: 'Cronologia',
       history: 'Cronologia',
       timeline: 'Linea temporale',
       timeline: 'Linea temporale',
+      pipelines: 'Pipeline',
     },
     },
     layout: {
     layout: {
       flatList: 'Elenco',
       flatList: 'Elenco',
@@ -2612,6 +2627,11 @@ export default {
           round_robin: 'Round robin — alterna tra stampanti idonee',
           round_robin: 'Round robin — alterna tra stampanti idonee',
           fill_one_first: 'Riempi una prima — assegna tutte le copie a una stampante',
           fill_one_first: 'Riempi una prima — assegna tutte le copie a una stampante',
         },
         },
+        fanoutShort: {
+          max_parallel: 'parallelo',
+          round_robin: 'round robin',
+          fill_one_first: 'prima una',
+        },
       },
       },
       action: {
       action: {
         save: 'Salva',
         save: 'Salva',
@@ -2624,8 +2644,22 @@ export default {
         process: 'Processo',
         process: 'Processo',
         filament: 'Filamento',
         filament: 'Filamento',
         filamentN: 'Filamento {{n}}',
         filamentN: 'Filamento {{n}}',
+        filamentAll: 'Tutti i {{n}} slot',
         bed: 'Piatto',
         bed: 'Piatto',
       },
       },
+      group: {
+        profiles: 'Profili',
+        filaments: 'Filamenti',
+      },
+      searchPlaceholder: 'Cerca pipeline…',
+      filterTargetType: 'Filtra per tipo di destinazione',
+      filterTarget: 'Filtra per destinazione',
+      filter: {
+        all: 'Tutte le destinazioni',
+        noTarget: 'Nessuna destinazione',
+        count: '{{shown}} / {{total}}',
+        noMatches: 'Nessuna pipeline corrisponde ai filtri attuali.',
+      },
       toast: {
       toast: {
         saved: 'Pipeline salvata',
         saved: 'Pipeline salvata',
         saveFailed: 'Salvataggio non riuscito',
         saveFailed: 'Salvataggio non riuscito',

+ 35 - 1
frontend/src/i18n/locales/ja.ts

@@ -4,7 +4,6 @@ export default {
     printers: 'プリンター',
     printers: 'プリンター',
     archives: 'アーカイブ',
     archives: 'アーカイブ',
     queue: '印刷キュー',
     queue: '印刷キュー',
-    pipelineRuns: 'パイプライン実行',
     stats: '統計',
     stats: '統計',
     profiles: 'プロファイル',
     profiles: 'プロファイル',
     maintenance: 'メンテナンス',
     maintenance: 'メンテナンス',
@@ -1054,8 +1053,16 @@ export default {
     filter: {
     filter: {
       pipeline: 'パイプライン',
       pipeline: 'パイプライン',
       status: 'ステータス',
       status: 'ステータス',
+      target: '対象',
       all: 'すべて',
       all: 'すべて',
+      allPipelines: 'すべてのパイプライン',
+      allStatus: 'すべてのステータス',
+      allTargets: 'すべての対象',
+      clear: 'フィルターをクリア',
+      noMatches: '現在のフィルターに一致する実行はありません。',
     },
     },
+    totalCount_one: '{{n}} 件の実行',
+    totalCount_other: '{{n}} 件の実行',
     copies: '{{n}} 部',
     copies: '{{n}} 部',
     failedCount: '{{n}} 失敗',
     failedCount: '{{n}} 失敗',
     copyN: 'コピー {{n}}',
     copyN: 'コピー {{n}}',
@@ -1067,7 +1074,13 @@ export default {
       cancelFailed: 'キャンセルに失敗しました',
       cancelFailed: 'キャンセルに失敗しました',
       retryStarted: '再試行を開始しました',
       retryStarted: '再試行を開始しました',
       retryFailed: '再試行に失敗しました',
       retryFailed: '再試行に失敗しました',
+      cleared: '{{n}} 件の実行を削除しました',
+      clearFailed: '削除に失敗しました',
     },
     },
+    clearLog: 'ログをクリア',
+    clearConfirmTitle: 'ログをクリアしますか?',
+    clearConfirmBody: '完了、失敗、キャンセル、部分的失敗のすべてのパイプライン実行を削除しますか?実行中のものは残ります。元に戻せません。',
+    clearConfirmAction: 'クリア',
     jobStatus: {
     jobStatus: {
       pending: '保留中',
       pending: '保留中',
       awaiting_printer: 'プリンター待機中',
       awaiting_printer: 'プリンター待機中',
@@ -1077,6 +1090,7 @@ export default {
       failed: '失敗',
       failed: '失敗',
       cancelled: 'キャンセル',
       cancelled: 'キャンセル',
     },
     },
+    cancelledByUser: 'ユーザーによりキャンセル',
   },
   },
 
 
   // Queue page
   // Queue page
@@ -1158,6 +1172,7 @@ export default {
       queue: 'キュー',
       queue: 'キュー',
       history: '履歴',
       history: '履歴',
       timeline: 'タイムライン',
       timeline: 'タイムライン',
+      pipelines: 'パイプライン',
     },
     },
     layout: {
     layout: {
       flatList: 'リスト',
       flatList: 'リスト',
@@ -2624,6 +2639,11 @@ export default {
           round_robin: 'ラウンドロビン — 適格なプリンター間で循環',
           round_robin: 'ラウンドロビン — 適格なプリンター間で循環',
           fill_one_first: '1台ずつ埋める — すべてのコピーを1台に固定',
           fill_one_first: '1台ずつ埋める — すべてのコピーを1台に固定',
         },
         },
+        fanoutShort: {
+          max_parallel: '並列',
+          round_robin: 'ラウンドロビン',
+          fill_one_first: '1台ずつ',
+        },
       },
       },
       action: {
       action: {
         save: '保存',
         save: '保存',
@@ -2636,8 +2656,22 @@ export default {
         process: 'プロセス',
         process: 'プロセス',
         filament: 'フィラメント',
         filament: 'フィラメント',
         filamentN: 'フィラメント {{n}}',
         filamentN: 'フィラメント {{n}}',
+        filamentAll: '{{n}} スロットすべて',
         bed: 'ベッド',
         bed: 'ベッド',
       },
       },
+      group: {
+        profiles: 'プロファイル',
+        filaments: 'フィラメント',
+      },
+      searchPlaceholder: 'パイプラインを検索…',
+      filterTargetType: '対象種別でフィルター',
+      filterTarget: '対象でフィルター',
+      filter: {
+        all: 'すべての対象',
+        noTarget: '対象未設定',
+        count: '{{shown}} / {{total}}',
+        noMatches: '現在のフィルターに一致するパイプラインはありません。',
+      },
       toast: {
       toast: {
         saved: 'パイプラインを保存しました',
         saved: 'パイプラインを保存しました',
         saveFailed: '保存に失敗しました',
         saveFailed: '保存に失敗しました',

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

@@ -3,7 +3,6 @@ export default {
     printers: '프린터',
     printers: '프린터',
     archives: '아카이브',
     archives: '아카이브',
     queue: '대기열',
     queue: '대기열',
-    pipelineRuns: '파이프라인 실행',
     stats: '통계',
     stats: '통계',
     profiles: '프로필',
     profiles: '프로필',
     maintenance: '유지보수',
     maintenance: '유지보수',
@@ -1009,8 +1008,16 @@ export default {
     filter: {
     filter: {
       pipeline: '파이프라인',
       pipeline: '파이프라인',
       status: '상태',
       status: '상태',
+      target: '대상',
       all: '전체',
       all: '전체',
+      allPipelines: '모든 파이프라인',
+      allStatus: '모든 상태',
+      allTargets: '모든 대상',
+      clear: '필터 지우기',
+      noMatches: '현재 필터와 일치하는 실행이 없습니다.',
     },
     },
+    totalCount_one: '{{n}}회 실행',
+    totalCount_other: '{{n}}회 실행',
     copies: '사본 {{n}}',
     copies: '사본 {{n}}',
     failedCount: '실패 {{n}}',
     failedCount: '실패 {{n}}',
     copyN: '사본 {{n}}',
     copyN: '사본 {{n}}',
@@ -1022,7 +1029,13 @@ export default {
       cancelFailed: '취소 실패',
       cancelFailed: '취소 실패',
       retryStarted: '재시도 시작됨',
       retryStarted: '재시도 시작됨',
       retryFailed: '재시도 실패',
       retryFailed: '재시도 실패',
+      cleared: '{{n}}회 실행 삭제됨',
+      clearFailed: '삭제 실패',
     },
     },
+    clearLog: '로그 지우기',
+    clearConfirmTitle: '로그를 지울까요?',
+    clearConfirmBody: '완료, 실패, 취소, 부분 실패한 모든 파이프라인 실행을 삭제합니까? 진행 중인 실행은 유지됩니다. 되돌릴 수 없습니다.',
+    clearConfirmAction: '지우기',
     jobStatus: {
     jobStatus: {
       pending: '대기 중',
       pending: '대기 중',
       awaiting_printer: '프린터 대기 중',
       awaiting_printer: '프린터 대기 중',
@@ -1032,6 +1045,7 @@ export default {
       failed: '실패',
       failed: '실패',
       cancelled: '취소됨',
       cancelled: '취소됨',
     },
     },
+    cancelledByUser: '사용자에 의해 취소됨',
   },
   },
 
 
   queue: {
   queue: {
@@ -1101,6 +1115,7 @@ export default {
       queue: '큐',
       queue: '큐',
       history: '기록',
       history: '기록',
       timeline: '타임라인',
       timeline: '타임라인',
+      pipelines: '파이프라인',
     },
     },
     layout: {
     layout: {
       flatList: '목록',
       flatList: '목록',
@@ -2480,6 +2495,11 @@ export default {
           round_robin: '라운드 로빈 — 적격 프린터를 순환',
           round_robin: '라운드 로빈 — 적격 프린터를 순환',
           fill_one_first: '하나 먼저 채우기 — 모든 사본을 한 프린터에 고정',
           fill_one_first: '하나 먼저 채우기 — 모든 사본을 한 프린터에 고정',
         },
         },
+        fanoutShort: {
+          max_parallel: '병렬',
+          round_robin: '라운드 로빈',
+          fill_one_first: '하나 먼저',
+        },
       },
       },
       action: {
       action: {
         save: '저장',
         save: '저장',
@@ -2492,8 +2512,22 @@ export default {
         process: '프로세스',
         process: '프로세스',
         filament: '필라멘트',
         filament: '필라멘트',
         filamentN: '필라멘트 {{n}}',
         filamentN: '필라멘트 {{n}}',
+        filamentAll: '전체 {{n}} 슬롯',
         bed: '베드',
         bed: '베드',
       },
       },
+      group: {
+        profiles: '프로필',
+        filaments: '필라멘트',
+      },
+      searchPlaceholder: '파이프라인 검색…',
+      filterTargetType: '대상 유형으로 필터',
+      filterTarget: '대상으로 필터',
+      filter: {
+        all: '모든 대상',
+        noTarget: '대상 미설정',
+        count: '{{shown}} / {{total}}',
+        noMatches: '현재 필터와 일치하는 파이프라인이 없습니다.',
+      },
       toast: {
       toast: {
         saved: '파이프라인이 저장되었습니다',
         saved: '파이프라인이 저장되었습니다',
         saveFailed: '저장 실패',
         saveFailed: '저장 실패',

+ 36 - 2
frontend/src/i18n/locales/pt-BR.ts

@@ -4,7 +4,6 @@ export default {
     printers: 'Impressoras',
     printers: 'Impressoras',
     archives: 'Arquivos',
     archives: 'Arquivos',
     queue: 'Fila de impressão',
     queue: 'Fila de impressão',
-    pipelineRuns: 'Execuções de pipeline',
     stats: 'Estatísticas',
     stats: 'Estatísticas',
     profiles: 'Perfis',
     profiles: 'Perfis',
     maintenance: 'Manutenção',
     maintenance: 'Manutenção',
@@ -1055,8 +1054,16 @@ export default {
     filter: {
     filter: {
       pipeline: 'Pipeline',
       pipeline: 'Pipeline',
       status: 'Status',
       status: 'Status',
+      target: 'Destino',
       all: 'Todas',
       all: 'Todas',
-    },
+      allPipelines: 'Todas as pipelines',
+      allStatus: 'Todos os status',
+      allTargets: 'Todos os destinos',
+      clear: 'Limpar filtros',
+      noMatches: 'Nenhuma execução corresponde aos filtros atuais.',
+    },
+    totalCount_one: '{{n}} execução',
+    totalCount_other: '{{n}} execuções',
     copies: '{{n}} cópias',
     copies: '{{n}} cópias',
     failedCount: '{{n}} falharam',
     failedCount: '{{n}} falharam',
     copyN: 'Cópia {{n}}',
     copyN: 'Cópia {{n}}',
@@ -1068,7 +1075,13 @@ export default {
       cancelFailed: 'Cancelamento falhou',
       cancelFailed: 'Cancelamento falhou',
       retryStarted: 'Nova tentativa iniciada',
       retryStarted: 'Nova tentativa iniciada',
       retryFailed: 'Nova tentativa falhou',
       retryFailed: 'Nova tentativa falhou',
+      cleared: '{{n}} execuções limpas',
+      clearFailed: 'Falha ao limpar',
     },
     },
+    clearLog: 'Limpar histórico',
+    clearConfirmTitle: 'Limpar histórico?',
+    clearConfirmBody: 'Excluir todas as execuções de pipeline concluídas, falhas, canceladas e com falha parcial? Execuções em andamento são mantidas. Isso não pode ser desfeito.',
+    clearConfirmAction: 'Limpar',
     jobStatus: {
     jobStatus: {
       pending: 'pendente',
       pending: 'pendente',
       awaiting_printer: 'aguardando impressora',
       awaiting_printer: 'aguardando impressora',
@@ -1078,6 +1091,7 @@ export default {
       failed: 'falhou',
       failed: 'falhou',
       cancelled: 'cancelada',
       cancelled: 'cancelada',
     },
     },
+    cancelledByUser: 'Cancelado pelo usuário',
   },
   },
 
 
   // Queue page
   // Queue page
@@ -1159,6 +1173,7 @@ export default {
       queue: 'Fila',
       queue: 'Fila',
       history: 'Histórico',
       history: 'Histórico',
       timeline: 'Linha do tempo',
       timeline: 'Linha do tempo',
+      pipelines: 'Processos',
     },
     },
     layout: {
     layout: {
       flatList: 'Lista',
       flatList: 'Lista',
@@ -2612,6 +2627,11 @@ export default {
           round_robin: 'Round robin — alternar entre impressoras elegíveis',
           round_robin: 'Round robin — alternar entre impressoras elegíveis',
           fill_one_first: 'Encher uma primeiro — fixar todas as cópias em uma impressora',
           fill_one_first: 'Encher uma primeiro — fixar todas as cópias em uma impressora',
         },
         },
+        fanoutShort: {
+          max_parallel: 'paralelo',
+          round_robin: 'round robin',
+          fill_one_first: 'primeiro um',
+        },
       },
       },
       action: {
       action: {
         save: 'Salvar',
         save: 'Salvar',
@@ -2624,8 +2644,22 @@ export default {
         process: 'Processo',
         process: 'Processo',
         filament: 'Filamento',
         filament: 'Filamento',
         filamentN: 'Filamento {{n}}',
         filamentN: 'Filamento {{n}}',
+        filamentAll: 'Todos os {{n}} slots',
         bed: 'Mesa',
         bed: 'Mesa',
       },
       },
+      group: {
+        profiles: 'Perfis',
+        filaments: 'Filamentos',
+      },
+      searchPlaceholder: 'Buscar pipelines…',
+      filterTargetType: 'Filtrar por tipo de destino',
+      filterTarget: 'Filtrar por destino',
+      filter: {
+        all: 'Todos os destinos',
+        noTarget: 'Sem destino',
+        count: '{{shown}} / {{total}}',
+        noMatches: 'Nenhuma pipeline corresponde aos filtros atuais.',
+      },
       toast: {
       toast: {
         saved: 'Pipeline salva',
         saved: 'Pipeline salva',
         saveFailed: 'Falha ao salvar',
         saveFailed: 'Falha ao salvar',

+ 36 - 2
frontend/src/i18n/locales/tr.ts

@@ -4,7 +4,6 @@ export default {
     printers: 'Yazıcılar',
     printers: 'Yazıcılar',
     archives: 'Arşivler',
     archives: 'Arşivler',
     queue: 'Baskı Kuyruğu',
     queue: 'Baskı Kuyruğu',
-    pipelineRuns: 'Pipeline çalıştırmaları',
     stats: 'İstatistikler',
     stats: 'İstatistikler',
     profiles: 'Profiller',
     profiles: 'Profiller',
     maintenance: 'Bakım',
     maintenance: 'Bakım',
@@ -1056,8 +1055,16 @@ export default {
     filter: {
     filter: {
       pipeline: 'Pipeline',
       pipeline: 'Pipeline',
       status: 'Durum',
       status: 'Durum',
+      target: 'Hedef',
       all: 'Tümü',
       all: 'Tümü',
-    },
+      allPipelines: 'Tüm pipeline\'lar',
+      allStatus: 'Tüm durumlar',
+      allTargets: 'Tüm hedefler',
+      clear: 'Filtreleri temizle',
+      noMatches: 'Mevcut filtrelere uyan çalıştırma yok.',
+    },
+    totalCount_one: '{{n}} çalıştırma',
+    totalCount_other: '{{n}} çalıştırma',
     copies: '{{n}} kopya',
     copies: '{{n}} kopya',
     failedCount: '{{n}} başarısız',
     failedCount: '{{n}} başarısız',
     copyN: 'Kopya {{n}}',
     copyN: 'Kopya {{n}}',
@@ -1069,7 +1076,13 @@ export default {
       cancelFailed: 'İptal başarısız',
       cancelFailed: 'İptal başarısız',
       retryStarted: 'Yeniden deneme başlatıldı',
       retryStarted: 'Yeniden deneme başlatıldı',
       retryFailed: 'Yeniden deneme başarısız',
       retryFailed: 'Yeniden deneme başarısız',
+      cleared: '{{n}} çalıştırma temizlendi',
+      clearFailed: 'Temizleme başarısız',
     },
     },
+    clearLog: 'Geçmişi temizle',
+    clearConfirmTitle: 'Geçmiş temizlensin mi?',
+    clearConfirmBody: 'Tamamlanan, başarısız olan, iptal edilen ve kısmen başarısız tüm pipeline çalıştırmaları silinsin mi? Devam eden çalıştırmalar korunur. Bu geri alınamaz.',
+    clearConfirmAction: 'Temizle',
     jobStatus: {
     jobStatus: {
       pending: 'beklemede',
       pending: 'beklemede',
       awaiting_printer: 'yazıcı bekleniyor',
       awaiting_printer: 'yazıcı bekleniyor',
@@ -1079,6 +1092,7 @@ export default {
       failed: 'başarısız',
       failed: 'başarısız',
       cancelled: 'iptal edildi',
       cancelled: 'iptal edildi',
     },
     },
+    cancelledByUser: 'Kullanıcı tarafından iptal edildi',
   },
   },
 
 
   queue: {
   queue: {
@@ -1159,6 +1173,7 @@ export default {
       queue: 'Kuyruk',
       queue: 'Kuyruk',
       history: 'Geçmiş',
       history: 'Geçmiş',
       timeline: 'Zaman çizelgesi',
       timeline: 'Zaman çizelgesi',
+      pipelines: 'Pipeline\'lar',
     },
     },
     layout: {
     layout: {
       flatList: 'Liste',
       flatList: 'Liste',
@@ -2628,6 +2643,11 @@ export default {
           round_robin: 'Sırayla — uygun yazıcılar arasında döndür',
           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',
           fill_one_first: 'Önce birini doldur — tüm kopyaları tek yazıcıya sabitle',
         },
         },
+        fanoutShort: {
+          max_parallel: 'paralel',
+          round_robin: 'sırayla',
+          fill_one_first: 'önce bir',
+        },
       },
       },
       action: {
       action: {
         save: 'Kaydet',
         save: 'Kaydet',
@@ -2640,8 +2660,22 @@ export default {
         process: 'İşlem',
         process: 'İşlem',
         filament: 'Filament',
         filament: 'Filament',
         filamentN: 'Filament {{n}}',
         filamentN: 'Filament {{n}}',
+        filamentAll: 'Tüm {{n}} yuva',
         bed: 'Tabla',
         bed: 'Tabla',
       },
       },
+      group: {
+        profiles: 'Profiller',
+        filaments: 'Filamentler',
+      },
+      searchPlaceholder: 'Pipeline\'larda ara…',
+      filterTargetType: 'Hedef türüne göre filtrele',
+      filterTarget: 'Hedefe göre filtrele',
+      filter: {
+        all: 'Tüm hedefler',
+        noTarget: 'Hedef belirlenmemiş',
+        count: '{{shown}} / {{total}}',
+        noMatches: 'Mevcut filtrelere uyan pipeline yok.',
+      },
       toast: {
       toast: {
         saved: 'Pipeline kaydedildi',
         saved: 'Pipeline kaydedildi',
         saveFailed: 'Kaydetme başarısız',
         saveFailed: 'Kaydetme başarısız',

+ 36 - 2
frontend/src/i18n/locales/zh-CN.ts

@@ -4,7 +4,6 @@ export default {
     printers: '打印机',
     printers: '打印机',
     archives: '归档',
     archives: '归档',
     queue: '打印队列',
     queue: '打印队列',
-    pipelineRuns: '流水线运行',
     stats: '统计',
     stats: '统计',
     profiles: '配置文件',
     profiles: '配置文件',
     maintenance: '维护',
     maintenance: '维护',
@@ -1055,8 +1054,16 @@ export default {
     filter: {
     filter: {
       pipeline: '流水线',
       pipeline: '流水线',
       status: '状态',
       status: '状态',
+      target: '目标',
       all: '全部',
       all: '全部',
-    },
+      allPipelines: '全部流水线',
+      allStatus: '全部状态',
+      allTargets: '全部目标',
+      clear: '清除筛选',
+      noMatches: '没有运行匹配当前筛选条件。',
+    },
+    totalCount_one: '{{n}} 次运行',
+    totalCount_other: '{{n}} 次运行',
     copies: '{{n}} 份',
     copies: '{{n}} 份',
     failedCount: '{{n}} 失败',
     failedCount: '{{n}} 失败',
     copyN: '副本 {{n}}',
     copyN: '副本 {{n}}',
@@ -1068,7 +1075,13 @@ export default {
       cancelFailed: '取消失败',
       cancelFailed: '取消失败',
       retryStarted: '已开始重试',
       retryStarted: '已开始重试',
       retryFailed: '重试失败',
       retryFailed: '重试失败',
+      cleared: '已清除 {{n}} 次运行',
+      clearFailed: '清除失败',
     },
     },
+    clearLog: '清除日志',
+    clearConfirmTitle: '清除日志?',
+    clearConfirmBody: '删除所有已完成、失败、已取消和部分失败的流水线运行?进行中的运行将保留。此操作无法撤销。',
+    clearConfirmAction: '清除',
     jobStatus: {
     jobStatus: {
       pending: '等待中',
       pending: '等待中',
       awaiting_printer: '等待打印机',
       awaiting_printer: '等待打印机',
@@ -1078,6 +1091,7 @@ export default {
       failed: '失败',
       failed: '失败',
       cancelled: '已取消',
       cancelled: '已取消',
     },
     },
+    cancelledByUser: '用户已取消',
   },
   },
 
 
   // Queue page
   // Queue page
@@ -1159,6 +1173,7 @@ export default {
       queue: '队列',
       queue: '队列',
       history: '历史',
       history: '历史',
       timeline: '时间线',
       timeline: '时间线',
+      pipelines: '流水线',
     },
     },
     layout: {
     layout: {
       flatList: '列表',
       flatList: '列表',
@@ -2612,6 +2627,11 @@ export default {
           round_robin: '轮询 — 在合格打印机间循环',
           round_robin: '轮询 — 在合格打印机间循环',
           fill_one_first: '先填一台 — 将所有副本固定到一台打印机',
           fill_one_first: '先填一台 — 将所有副本固定到一台打印机',
         },
         },
+        fanoutShort: {
+          max_parallel: '并行',
+          round_robin: '轮询',
+          fill_one_first: '先填一台',
+        },
       },
       },
       action: {
       action: {
         save: '保存',
         save: '保存',
@@ -2624,8 +2644,22 @@ export default {
         process: '工艺',
         process: '工艺',
         filament: '耗材',
         filament: '耗材',
         filamentN: '耗材 {{n}}',
         filamentN: '耗材 {{n}}',
+        filamentAll: '全部 {{n}} 个槽',
         bed: '热床',
         bed: '热床',
       },
       },
+      group: {
+        profiles: '配置文件',
+        filaments: '耗材',
+      },
+      searchPlaceholder: '搜索流水线…',
+      filterTargetType: '按目标类型筛选',
+      filterTarget: '按目标筛选',
+      filter: {
+        all: '全部目标',
+        noTarget: '未设置目标',
+        count: '{{shown}} / {{total}}',
+        noMatches: '没有流水线匹配当前筛选条件。',
+      },
       toast: {
       toast: {
         saved: '流水线已保存',
         saved: '流水线已保存',
         saveFailed: '保存失败',
         saveFailed: '保存失败',

+ 36 - 2
frontend/src/i18n/locales/zh-TW.ts

@@ -4,7 +4,6 @@ export default {
     printers: '印表機',
     printers: '印表機',
     archives: '歸檔',
     archives: '歸檔',
     queue: '列印佇列',
     queue: '列印佇列',
-    pipelineRuns: '管線執行',
     stats: '統計',
     stats: '統計',
     profiles: '設定檔案',
     profiles: '設定檔案',
     maintenance: '維護',
     maintenance: '維護',
@@ -1055,8 +1054,16 @@ export default {
     filter: {
     filter: {
       pipeline: '管線',
       pipeline: '管線',
       status: '狀態',
       status: '狀態',
+      target: '目標',
       all: '全部',
       all: '全部',
-    },
+      allPipelines: '全部管線',
+      allStatus: '全部狀態',
+      allTargets: '全部目標',
+      clear: '清除篩選',
+      noMatches: '沒有執行符合目前的篩選條件。',
+    },
+    totalCount_one: '{{n}} 次執行',
+    totalCount_other: '{{n}} 次執行',
     copies: '{{n}} 份',
     copies: '{{n}} 份',
     failedCount: '{{n}} 失敗',
     failedCount: '{{n}} 失敗',
     copyN: '副本 {{n}}',
     copyN: '副本 {{n}}',
@@ -1068,7 +1075,13 @@ export default {
       cancelFailed: '取消失敗',
       cancelFailed: '取消失敗',
       retryStarted: '已開始重試',
       retryStarted: '已開始重試',
       retryFailed: '重試失敗',
       retryFailed: '重試失敗',
+      cleared: '已清除 {{n}} 次執行',
+      clearFailed: '清除失敗',
     },
     },
+    clearLog: '清除日誌',
+    clearConfirmTitle: '清除日誌?',
+    clearConfirmBody: '刪除所有已完成、失敗、已取消和部分失敗的管線執行?進行中的執行將保留。此操作無法復原。',
+    clearConfirmAction: '清除',
     jobStatus: {
     jobStatus: {
       pending: '等待中',
       pending: '等待中',
       awaiting_printer: '等待印表機',
       awaiting_printer: '等待印表機',
@@ -1078,6 +1091,7 @@ export default {
       failed: '失敗',
       failed: '失敗',
       cancelled: '已取消',
       cancelled: '已取消',
     },
     },
+    cancelledByUser: '使用者已取消',
   },
   },
 
 
   // Queue page
   // Queue page
@@ -1159,6 +1173,7 @@ export default {
       queue: '佇列',
       queue: '佇列',
       history: '歷史',
       history: '歷史',
       timeline: '時間軸',
       timeline: '時間軸',
+      pipelines: '管線',
     },
     },
     layout: {
     layout: {
       flatList: '清單',
       flatList: '清單',
@@ -2612,6 +2627,11 @@ export default {
           round_robin: '輪替 — 在合格印表機間循環',
           round_robin: '輪替 — 在合格印表機間循環',
           fill_one_first: '先填一台 — 將所有副本固定到一台印表機',
           fill_one_first: '先填一台 — 將所有副本固定到一台印表機',
         },
         },
+        fanoutShort: {
+          max_parallel: '並行',
+          round_robin: '輪替',
+          fill_one_first: '先填一台',
+        },
       },
       },
       action: {
       action: {
         save: '儲存',
         save: '儲存',
@@ -2624,8 +2644,22 @@ export default {
         process: '製程',
         process: '製程',
         filament: '耗材',
         filament: '耗材',
         filamentN: '耗材 {{n}}',
         filamentN: '耗材 {{n}}',
+        filamentAll: '全部 {{n}} 個槽',
         bed: '熱床',
         bed: '熱床',
       },
       },
+      group: {
+        profiles: '設定檔',
+        filaments: '耗材',
+      },
+      searchPlaceholder: '搜尋管線…',
+      filterTargetType: '依目標類型篩選',
+      filterTarget: '依目標篩選',
+      filter: {
+        all: '全部目標',
+        noTarget: '未設定目標',
+        count: '{{shown}} / {{total}}',
+        noMatches: '沒有管線符合目前的篩選條件。',
+      },
       toast: {
       toast: {
         saved: '管線已儲存',
         saved: '管線已儲存',
         saveFailed: '儲存失敗',
         saveFailed: '儲存失敗',

+ 443 - 105
frontend/src/pages/PipelineRunsPage.tsx

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

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

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

Файловите разлики са ограничени, защото са твърде много
+ 0 - 1
static/assets/index-B002rfYt.css


Файловите разлики са ограничени, защото са твърде много
+ 1 - 0
static/assets/index-BYzbe9TT.css


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
static/assets/index-ChnivgH9.js


+ 2 - 2
static/index.html

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

Някои файлове не бяха показани, защото твърде много файлове са промени