소스 검색

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 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 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
 
         live_job_status = _compute_job_status(job.status, queue_entry)
+        # If the job WAS dispatched (had a queue_entry_id) but the entry has
+        # since been deleted from the queue page, the user's intent was
+        # cancellation. Otherwise the run would stay forever showing as
+        # ``queued`` because the persisted job.status hasn't been updated.
+        if (
+            job.queue_entry_id is not None
+            and queue_entry is None
+            and live_job_status not in ("completed", "failed", "cancelled")
+        ):
+            live_job_status = "cancelled"
         job_live_statuses.append(live_job_status)
         job_responses.append(
             PipelineJobResponse(
@@ -465,6 +475,16 @@ def _make_orchestration_callable(
                 logger.warning("pipeline_run %d or pipeline %d disappeared mid-orchestration", run_id, pipeline_id)
                 return {}
 
+            # Honour a cancel that landed between ``POST /run`` returning and
+            # this background task starting. If the run was cancelled while
+            # still in ``queued`` we must NOT flip it back to ``slicing`` —
+            # the operator's intent was to stop, and overwriting status here
+            # was the bug that left runs stuck at ``dispatching`` after a
+            # user-side cancel (#1425 PR C bug report).
+            if run.status == "cancelled":
+                logger.info("pipeline_run %d was cancelled before slicing started", run_id)
+                return {}
+
             run.status = "slicing"
             run.started_at = datetime.now(timezone.utc)
             await session.commit()
@@ -512,6 +532,17 @@ def _make_orchestration_callable(
 
             run.sliced_library_file_id = slice_response.library_file_id
 
+            # Re-check cancellation: the slice can take minutes, and the
+            # operator may have hit Cancel during that window. Refresh from
+            # the DB rather than trusting our in-memory `run` (the cancel
+            # route writes via a separate session). When cancelled, don't
+            # enqueue print queue items — that's the whole point of cancel.
+            await session.refresh(run)
+            if run.status == "cancelled":
+                logger.info("pipeline_run %d cancelled mid-slice; skipping queue enqueue", run_id)
+                await session.commit()
+                return slice_response.model_dump()
+
             # PR C: enqueue N copies per the picked assignment strategy.
             assignments = await _pick_assignments(session, pipeline, copies)
 
@@ -542,9 +573,40 @@ def _make_orchestration_callable(
 
                 job.queue_entry_id = queue_item.id
                 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)
 
+            # Final cancellation check before committing 'dispatching'. The
+            # cancel route writes via a separate session so we have to refresh
+            # to see the latest. If the cancel landed in this narrow window —
+            # AFTER the post-slice refresh but BEFORE this commit — the queue
+            # entries we just created would otherwise pick up and print. Mark
+            # them + the per-copy jobs cancelled so the user's intent sticks.
+            await session.refresh(run)
+            if run.status == "cancelled":
+                logger.info(
+                    "pipeline_run %d cancelled in the dispatch window; cancelling its %d queue entries",
+                    run_id,
+                    len(jobs),
+                )
+                for job in jobs:
+                    if job.queue_entry_id:
+                        qe = (
+                            await session.execute(select(PrintQueueItem).where(PrintQueueItem.id == job.queue_entry_id))
+                        ).scalar_one_or_none()
+                        if qe is not None and qe.status in ("pending", "queued"):
+                            qe.status = "cancelled"
+                    if job.status not in ("completed", "failed", "cancelled"):
+                        job.status = "cancelled"
+                        job.completed_at = datetime.now(timezone.utc)
+                await session.commit()
+                await _publish_run_event(session, run)
+                return slice_response.model_dump()
+
+            for job in jobs:
+                job.status = "queued"
             run.status = "dispatching"
             await session.commit()
             await _publish_run_event(session, run)
@@ -720,13 +782,17 @@ async def list_all_runs(
     offset: int = 0,
     pipeline_id: int | None = None,
     status: str | None = None,
+    target_printer_id: int | None = None,
+    target_model_class: str | None = None,
     _: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_READ),
     db: AsyncSession = Depends(get_db),
 ):
-    """Dashboard list. Newest first; filters on pipeline_id + status. 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))
     offset = max(0, offset)
 
@@ -738,6 +804,15 @@ async def list_all_runs(
     if status:
         stmt = stmt.where(PipelineRun.status == status)
         count_stmt = count_stmt.where(PipelineRun.status == status)
+    if target_printer_id is not None or target_model_class is not None:
+        stmt = stmt.join(SlicerPipeline, SlicerPipeline.id == PipelineRun.pipeline_id)
+        count_stmt = count_stmt.join(SlicerPipeline, SlicerPipeline.id == PipelineRun.pipeline_id)
+        if target_printer_id is not None:
+            stmt = stmt.where(SlicerPipeline.target_printer_id == target_printer_id)
+            count_stmt = count_stmt.where(SlicerPipeline.target_printer_id == target_printer_id)
+        if target_model_class is not None:
+            stmt = stmt.where(SlicerPipeline.target_model_class == target_model_class)
+            count_stmt = count_stmt.where(SlicerPipeline.target_model_class == target_model_class)
 
     rows = (await db.execute(stmt.order_by(desc(PipelineRun.id)).offset(offset).limit(limit))).scalars().all()
     total = (await db.execute(count_stmt)).scalar() or 0
@@ -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)
 async def get_run(
     run_id: int,

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

@@ -3345,7 +3345,7 @@ async def seed_default_groups():
 
     from sqlalchemy import select
 
-    from backend.app.core.permissions import DEFAULT_GROUPS
+    from backend.app.core.permissions import ALL_PERMISSIONS, DEFAULT_GROUPS
     from backend.app.models.group import Group
     from backend.app.models.user import User
 
@@ -3498,63 +3498,31 @@ async def seed_default_groups():
                 group.permissions = perms
         await session.commit()
 
-        # Backfill library:purge + archives:purge for the Administrators group
-        # on existing installs. Both permissions were added after Administrators
-        # was first seeded, so upgrading users miss them even though the default
-        # config (ALL_PERMISSIONS) includes them for fresh installs.
-        result = await session.execute(select(Group).where(Group.name == "Administrators"))
-        admin_group = result.scalar_one_or_none()
-        if admin_group and admin_group.permissions is not None:
-            perms = list(admin_group.permissions)
-            added = False
-            for new_perm in ("library:purge", "archives:purge"):
-                if new_perm not in perms:
-                    perms.append(new_perm)
-                    added = True
-                    logger.info("Added %s to Administrators group (backfill)", new_perm)
-            if added:
-                admin_group.permissions = perms
-        await session.commit()
-
-        # Backfill the read flag set for the Administrators group on existing
-        # installs (maziggy/bambuddy-security #2). Two layers:
-        #
-        # (a) New OWN/ALL splits — `archives:read_own` etc. Fresh installs get
-        #     these via ALL_PERMISSIONS; upgrades need the explicit backfill
-        #     so admin's permission set matches a fresh install's.
+        # Backfill: sync the Administrators system group to ALL_PERMISSIONS.
+        # Administrators' contract is full access to every feature — fresh
+        # installs get that via DEFAULT_GROUPS["Administrators"]["permissions"]
+        # = ALL_PERMISSIONS. Upgrading installs would otherwise stay frozen at
+        # whatever permission set existed when they were first seeded, so a
+        # newly-added Permission enum member silently leaves admins gated out
+        # of the feature it controls.
         #
-        # (b) Legacy `archives:read` / `library:read` / `queue:read`. The
-        #     frontend still gates download / preview UI on these LEGACY
-        #     strings (see ArchivesPage / FileManagerPage), so admin needs
-        #     them retained even though the new API uses the OWN/ALL split.
-        #     The PERMISSION_MIGRATION_ALL map deliberately doesn't rename
-        #     read flags for admin — this backfill ensures they're present
-        #     even if they were stripped by hand or by an older migration.
-        #
-        # Also includes orca_cloud:auth for parity with fresh-install
-        # behaviour (ALL_PERMISSIONS covers it; backfill makes sure an
-        # admin role that's been customised since seed still has it).
+        # Generalises the previous one-off admin backfills (library:purge,
+        # archives:purge, the OWN/ALL read-flag set + legacy read flags,
+        # orca_cloud:auth, printer_sensor_history:read, …): every current
+        # Permission enum value is appended to the admin group if missing.
+        # Additive only — never removes a permission an operator added by
+        # hand. Run AFTER the legacy-rename migration above so the renamed
+        # OWN/ALL variants land in the group before the sync sees them.
         result = await session.execute(select(Group).where(Group.name == "Administrators"))
         admin_group = result.scalar_one_or_none()
         if admin_group and admin_group.permissions is not None:
             perms = list(admin_group.permissions)
             added = False
-            for new_perm in (
-                "archives:read",
-                "archives:read_own",
-                "archives:read_all",
-                "library:read",
-                "library:read_own",
-                "library:read_all",
-                "queue:read",
-                "queue:read_own",
-                "queue:read_all",
-                "orca_cloud:auth",
-            ):
+            for new_perm in ALL_PERMISSIONS:
                 if new_perm not in perms:
                     perms.append(new_perm)
                     added = True
-                    logger.info("Added %s to Administrators group (backfill)", new_perm)
+                    logger.info("Added %s to Administrators group (ALL_PERMISSIONS sync)", new_perm)
             if added:
                 admin_group.permissions = perms
         await session.commit()
@@ -3613,25 +3581,18 @@ async def seed_default_groups():
                 group.permissions = perms
         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)
-        #   - Viewers + any group with library:read_own or settings:read:
+        #   - Any other group with library:read_own or settings:read:
         #     pipelines:read only
         result = await session.execute(select(Group))
         for group in result.scalars().all():
-            if not group.permissions:
+            if not group.permissions or group.name == "Administrators":
                 continue
             perms = list(group.permissions)
             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"):
                     if new_perm not in perms:
                         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
 
 
+class TestPolishFollowUp:
+    """Polish-pass fixes: dashboard target filters, clear endpoint, and the
+    deleted-queue-entry → cancelled rollup behaviour."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_dashboard_filters_by_target_printer(
+        self,
+        async_client: AsyncClient,
+        pipeline_factory,
+        printer_factory,
+        library_file_factory,
+        db_session,
+    ):
+        from backend.app.models.pipeline_run import PipelineRun
+
+        printer_a = await printer_factory()
+        printer_b = await printer_factory()
+        pipe_a = await pipeline_factory(target_printer_id=printer_a.id)
+        pipe_b = await pipeline_factory(target_printer_id=printer_b.id)
+        src = await library_file_factory()
+        for pipe in (pipe_a, pipe_a, pipe_b):
+            db_session.add(
+                PipelineRun(
+                    pipeline_id=pipe["id"],
+                    source_library_file_id=src.id,
+                    copies=1,
+                    status="completed",
+                )
+            )
+        await db_session.commit()
+
+        resp = await async_client.get(f"/api/v1/pipeline-runs?target_printer_id={printer_a.id}")
+        assert resp.status_code == 200
+        body = resp.json()
+        assert body["total"] == 2
+        assert all(r["target_printer_id"] == printer_a.id for r in body["runs"])
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_dashboard_filters_by_target_model_class(
+        self,
+        async_client: AsyncClient,
+        pipeline_factory,
+        printer_factory,
+        library_file_factory,
+        db_session,
+    ):
+        from backend.app.models.pipeline_run import PipelineRun
+
+        await printer_factory(model="X1C")
+        await printer_factory(model="P1S")
+        # Two pipelines, one class-targeting X1C, one P1S.
+        pipe_x = await pipeline_factory()
+        await async_client.put(
+            f"/api/v1/slicer-pipelines/{pipe_x['id']}",
+            json={"target_kind": "printer_class", "target_printer_id": 0, "target_model_class": "X1C"},
+        )
+        pipe_p = await pipeline_factory()
+        await async_client.put(
+            f"/api/v1/slicer-pipelines/{pipe_p['id']}",
+            json={"target_kind": "printer_class", "target_printer_id": 0, "target_model_class": "P1S"},
+        )
+        src = await library_file_factory()
+        for pipe in (pipe_x, pipe_p, pipe_p):
+            db_session.add(
+                PipelineRun(
+                    pipeline_id=pipe["id"],
+                    source_library_file_id=src.id,
+                    copies=1,
+                    status="completed",
+                )
+            )
+        await db_session.commit()
+
+        resp = await async_client.get("/api/v1/pipeline-runs?target_model_class=P1S")
+        assert resp.status_code == 200
+        body = resp.json()
+        assert body["total"] == 2
+        assert all(r["target_model_class"] == "P1S" for r in body["runs"])
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_clear_endpoint_deletes_terminal_runs_only(
+        self,
+        async_client: AsyncClient,
+        pipeline_factory,
+        printer_factory,
+        library_file_factory,
+        db_session,
+    ):
+        from backend.app.models.pipeline_run import PipelineRun
+
+        printer = await printer_factory()
+        pipe = await pipeline_factory(target_printer_id=printer.id)
+        src = await library_file_factory()
+        for status in ("completed", "failed", "cancelled", "partial_failure", "dispatching", "in_progress"):
+            db_session.add(
+                PipelineRun(
+                    pipeline_id=pipe["id"],
+                    source_library_file_id=src.id,
+                    copies=1,
+                    status=status,
+                )
+            )
+        await db_session.commit()
+
+        resp = await async_client.post("/api/v1/pipeline-runs/clear")
+        assert resp.status_code == 200, resp.text
+        assert resp.json()["deleted"] == 4  # 4 terminal statuses cleared
+
+        # The in-flight rows survive.
+        survivors = (await async_client.get("/api/v1/pipeline-runs")).json()
+        assert survivors["total"] == 2
+        assert {r["status"] for r in survivors["runs"]} == {"dispatching", "in_progress"}
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_deleted_queue_entry_rolls_up_as_cancelled(
+        self,
+        async_client: AsyncClient,
+        pipeline_factory,
+        printer_factory,
+        library_file_factory,
+        db_session,
+    ):
+        """When the queue entry that a PipelineJob is linked to gets deleted
+        from the print-queue page, the job's live status should roll up to
+        ``cancelled`` so the run doesn't sit forever showing ``queued`` /
+        ``dispatching``."""
+        from backend.app.models.pipeline_run import PipelineJob, PipelineRun
+
+        printer = await printer_factory()
+        pipe = await pipeline_factory(target_printer_id=printer.id)
+        src = await library_file_factory()
+        # Simulate the state PR C leaves a successful dispatch in: run is
+        # 'dispatching' and the job has a queue_entry_id pointing at a
+        # PrintQueueItem that no longer exists.
+        run = PipelineRun(
+            pipeline_id=pipe["id"],
+            source_library_file_id=src.id,
+            copies=1,
+            status="dispatching",
+        )
+        db_session.add(run)
+        await db_session.flush()
+        db_session.add(
+            PipelineJob(
+                pipeline_run_id=run.id,
+                copy_index=0,
+                queue_entry_id=999999,  # Doesn't exist — simulates manual delete from queue.
+                assigned_printer_id=printer.id,
+                status="queued",
+            )
+        )
+        await db_session.commit()
+        await db_session.refresh(run)
+
+        resp = await async_client.get(f"/api/v1/pipeline-runs/{run.id}")
+        assert resp.status_code == 200, resp.text
+        body = resp.json()
+        # Job rolled up to cancelled because the queue entry is gone.
+        assert body["jobs"][0]["status"] == "cancelled"
+        # Run also rolls up — all jobs cancelled → run reads as cancelled.
+        assert body["status"] == "cancelled"
+
+
 class TestCancelTerminal:
     @pytest.mark.asyncio
     @pytest.mark.integration

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

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

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

+ 4 - 2
frontend/src/App.tsx

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

+ 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 userEvent from '@testing-library/user-event';
 import { render } from '../utils';
-import { PipelineRunsPage } from '../../pages/PipelineRunsPage';
+import { PipelineRunsView } from '../../pages/PipelineRunsPage';
 import { api, type PipelineRun } from '../../api/client';
 
 vi.mock('../../api/client', () => ({
@@ -88,7 +88,7 @@ function makeRun(overrides: Partial<PipelineRun> = {}): PipelineRun {
   };
 }
 
-describe('PipelineRunsPage', () => {
+describe('PipelineRunsView', () => {
   beforeEach(() => {
     vi.clearAllMocks();
     mockApi.listSlicerPipelines.mockResolvedValue({ pipelines: [] });
@@ -96,7 +96,7 @@ describe('PipelineRunsPage', () => {
   });
 
   it('renders empty state when no runs exist', async () => {
-    render(<PipelineRunsPage />);
+    render(<PipelineRunsView />);
     await waitFor(() => {
       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 () => {
     mockApi.listAllPipelineRuns.mockResolvedValue({ runs: [makeRun()], total: 1 });
-    render(<PipelineRunsPage />);
+    render(<PipelineRunsView />);
     await waitFor(() => {
       expect(screen.getByText(/Production Batch/i)).toBeInTheDocument();
       // 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 () => {
     mockApi.listAllPipelineRuns.mockResolvedValue({ runs: [makeRun()], total: 1 });
     mockApi.cancelPipelineRun.mockResolvedValue(makeRun({ status: 'cancelled' }));
-    render(<PipelineRunsPage />);
+    render(<PipelineRunsView />);
     const user = userEvent.setup();
     await waitFor(() => expect(screen.getByText(/Production Batch/i)).toBeInTheDocument());
     await user.click(screen.getByRole('button', { name: /Cancel/i }));
@@ -135,7 +135,7 @@ describe('PipelineRunsPage', () => {
       total: 1,
     });
     mockApi.retryFailedPipelineRun.mockResolvedValue(makeRun({ id: 2, parent_run_id: 1, copies: 2 }));
-    render(<PipelineRunsPage />);
+    render(<PipelineRunsView />);
     const user = userEvent.setup();
     await waitFor(() => expect(screen.getByText(/Production Batch/i)).toBeInTheDocument());
     await user.click(screen.getByRole('button', { name: /Retry failed/i }));
@@ -144,7 +144,7 @@ describe('PipelineRunsPage', () => {
 
   it('expands the row to show per-copy jobs', async () => {
     mockApi.listAllPipelineRuns.mockResolvedValue({ runs: [makeRun()], total: 1 });
-    render(<PipelineRunsPage />);
+    render(<PipelineRunsView />);
     const user = userEvent.setup();
     await waitFor(() => expect(screen.getByText(/Production Batch/i)).toBeInTheDocument());
     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(
         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_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')
@@ -410,7 +410,6 @@ describe('SettingsPage', () => {
           'inventory',
           'archives',
           'queue',
-          'pipelineRuns',
           'projects',
           'files',
           'makerworld',

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

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

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

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

+ 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 { 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 {
   api,
   type PipelineRun,
@@ -62,14 +62,25 @@ export function SlicerPipelinesPanel() {
       description,
       target_printer_id,
       target_kind,
+      target_model_class,
+      fanout_strategy,
     }: {
       id: number;
       name?: string;
       description?: string | null;
       target_printer_id?: number | null;
       target_kind?: 'specific_printer' | 'printer_class';
+      target_model_class?: string | null;
+      fanout_strategy?: 'max_parallel' | 'fill_one_first' | 'round_robin';
     }) =>
-      api.updateSlicerPipeline(id, { name, description, target_printer_id, target_kind }),
+      api.updateSlicerPipeline(id, {
+        name,
+        description,
+        target_printer_id,
+        target_kind,
+        target_model_class,
+        fanout_strategy,
+      }),
     onSuccess: () => {
       queryClient.invalidateQueries({ queryKey: ['slicer-pipelines'] });
       showToast(t('settings.pipelines.toast.saved', 'Pipeline saved'), 'success');
@@ -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 (
     <Card>
@@ -118,7 +184,65 @@ export function SlicerPipelinesPanel() {
             {t('settings.pipelines.loadError', 'Could not load pipelines.')}
           </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">
             <p>{t('settings.pipelines.empty.title', 'No pipelines yet.')}</p>
             <p>
@@ -129,6 +253,11 @@ export function SlicerPipelinesPanel() {
             </p>
           </div>
         )}
+        {!isLoading && !error && allPipelines.length > 0 && pipelines.length === 0 && (
+          <p className="text-sm text-bambu-gray">
+            {t('settings.pipelines.filter.noMatches', 'No pipelines match the current filters.')}
+          </p>
+        )}
         {!isLoading && !error && pipelines.length > 0 && (
           <div className="space-y-2">
             {pipelines.map((p) => (
@@ -215,6 +344,19 @@ function PipelineRow({
   const printerName = resolveName(presets, 'printer', pipeline.printer_preset);
   const processName = resolveName(presets, 'process', pipeline.process_preset);
   const filamentResolutions = pipeline.filament_presets.map((f) => resolveName(presets, 'filament', f));
+  // Collapse identical filaments into a single "All N slots" line — most
+  // production pipelines load the same filament into every AMS slot, and
+  // listing the same line three times is just noise. Compares raw preset
+  // refs (source + id) rather than resolved names so the dedup is correct
+  // even when ``presets`` hasn't loaded yet.
+  const filamentsAllIdentical =
+    pipeline.filament_presets.length > 1 &&
+    pipeline.filament_presets.every(
+      (f) =>
+        f.source === pipeline.filament_presets[0].source &&
+        f.id === pipeline.filament_presets[0].id,
+    );
+
   const hasStaleRef =
     presets !== undefined &&
     (printerName === null || processName === null || filamentResolutions.some((n) => n === null));
@@ -381,7 +523,42 @@ function PipelineRow({
             </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 && (
                 <p className="text-xs text-bambu-gray mt-0.5">{pipeline.description}</p>
               )}
@@ -430,60 +607,68 @@ function PipelineRow({
       </div>
 
       {!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
-              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 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>

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@@ -4,7 +4,6 @@ export default {
     printers: '印表機',
     archives: '歸檔',
     queue: '列印佇列',
-    pipelineRuns: '管線執行',
     stats: '統計',
     profiles: '設定檔案',
     maintenance: '維護',
@@ -1055,8 +1054,16 @@ export default {
     filter: {
       pipeline: '管線',
       status: '狀態',
+      target: '目標',
       all: '全部',
-    },
+      allPipelines: '全部管線',
+      allStatus: '全部狀態',
+      allTargets: '全部目標',
+      clear: '清除篩選',
+      noMatches: '沒有執行符合目前的篩選條件。',
+    },
+    totalCount_one: '{{n}} 次執行',
+    totalCount_other: '{{n}} 次執行',
     copies: '{{n}} 份',
     failedCount: '{{n}} 失敗',
     copyN: '副本 {{n}}',
@@ -1068,7 +1075,13 @@ export default {
       cancelFailed: '取消失敗',
       retryStarted: '已開始重試',
       retryFailed: '重試失敗',
+      cleared: '已清除 {{n}} 次執行',
+      clearFailed: '清除失敗',
     },
+    clearLog: '清除日誌',
+    clearConfirmTitle: '清除日誌?',
+    clearConfirmBody: '刪除所有已完成、失敗、已取消和部分失敗的管線執行?進行中的執行將保留。此操作無法復原。',
+    clearConfirmAction: '清除',
     jobStatus: {
       pending: '等待中',
       awaiting_printer: '等待印表機',
@@ -1078,6 +1091,7 @@ export default {
       failed: '失敗',
       cancelled: '已取消',
     },
+    cancelledByUser: '使用者已取消',
   },
 
   // Queue page
@@ -1159,6 +1173,7 @@ export default {
       queue: '佇列',
       history: '歷史',
       timeline: '時間軸',
+      pipelines: '管線',
     },
     layout: {
       flatList: '清單',
@@ -2612,6 +2627,11 @@ export default {
           round_robin: '輪替 — 在合格印表機間循環',
           fill_one_first: '先填一台 — 將所有副本固定到一台印表機',
         },
+        fanoutShort: {
+          max_parallel: '並行',
+          round_robin: '輪替',
+          fill_one_first: '先填一台',
+        },
       },
       action: {
         save: '儲存',
@@ -2624,8 +2644,22 @@ export default {
         process: '製程',
         filament: '耗材',
         filamentN: '耗材 {{n}}',
+        filamentAll: '全部 {{n}} 個槽',
         bed: '熱床',
       },
+      group: {
+        profiles: '設定檔',
+        filaments: '耗材',
+      },
+      searchPlaceholder: '搜尋管線…',
+      filterTargetType: '依目標類型篩選',
+      filterTarget: '依目標篩選',
+      filter: {
+        all: '全部目標',
+        noTarget: '未設定目標',
+        count: '{{shown}} / {{total}}',
+        noMatches: '沒有管線符合目前的篩選條件。',
+      },
       toast: {
         saved: '管線已儲存',
         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 { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
 import {
+  Check,
   ChevronDown,
   ChevronRight,
+  FileText,
+  Filter,
   Loader2,
+  Printer as PrinterIcon,
   RefreshCw,
   RotateCcw,
+  Trash2,
   Workflow,
   X,
 } from 'lucide-react';
-import { api, type PipelineRun, type SlicerPipeline } from '../api/client';
+import { api, type PipelineRun, type Printer, type SlicerPipeline } from '../api/client';
 import { useToast } from '../contexts/ToastContext';
 
+type DropdownOption = { value: string; label: string; group?: string };
+
 const STATUSES = [
   '',
   'queued',
@@ -28,32 +35,61 @@ const STATUSES = [
 const PAGE_LIMIT = 25;
 
 // Dashboard for Slicer Pipeline runs (#1425 PR C).
-// Lists every run across every pipeline with status + pipeline filters and
-// pagination. Each row expands to show per-copy status; in-flight runs get a
-// Cancel button, partial-failure runs get a Retry-failed button.
-export function PipelineRunsPage() {
+// Renders the content of the Pipelines tab on the Print Queue page. Lists
+// every run across every pipeline with status + pipeline filters and
+// pagination; each row expands to show per-copy status; in-flight runs get a
+// Cancel button, partial-failure runs get a Retry-failed button. Designed to
+// embed inside QueuePage's tab strip — no page wrapper, no top-level title
+// (the tab strip provides both).
+export function PipelineRunsView() {
   const { t } = useTranslation();
   const queryClient = useQueryClient();
   const { showToast } = useToast();
 
   const [statusFilter, setStatusFilter] = useState<string>('');
   const [pipelineFilter, setPipelineFilter] = useState<number | null>(null);
+  // Target filter — same encoded shape as SlicerPipelinesPanel: '' = all,
+  // 'p:<printer_id>' = specific printer, 'c:<model_class>' = printer class.
+  const [targetFilter, setTargetFilter] = useState<string>('');
   const [offset, setOffset] = useState(0);
   const [expanded, setExpanded] = useState<Set<number>>(new Set());
+  const [showClearConfirm, setShowClearConfirm] = useState(false);
 
   const { data: pipelines } = useQuery({
     queryKey: ['slicer-pipelines'],
     queryFn: () => api.listSlicerPipelines(),
   });
+  // Used to resolve target_printer_id → printer name on each row so
+  // specific-printer runs show "H2D #1" instead of just an id.
+  const { data: printers } = useQuery({
+    queryKey: ['printers'],
+    queryFn: () => api.getPrinters(),
+  });
+  const printersById: Record<number, Printer> = (printers ?? []).reduce(
+    (acc, p) => {
+      acc[p.id] = p;
+      return acc;
+    },
+    {} as Record<number, Printer>,
+  );
+
+  const targetPrinterId = targetFilter.startsWith('p:')
+    ? parseInt(targetFilter.slice(2), 10)
+    : undefined;
+  const targetModelClass = targetFilter.startsWith('c:')
+    ? targetFilter.slice(2)
+    : undefined;
 
   const { data: runsList, isLoading } = useQuery({
-    queryKey: ['pipeline-runs-all', statusFilter, pipelineFilter, offset],
+    queryKey: ['pipeline-runs-all', statusFilter, pipelineFilter, offset, targetFilter],
     queryFn: () =>
       api.listAllPipelineRuns({
         limit: PAGE_LIMIT,
         offset,
         pipelineId: pipelineFilter ?? undefined,
         status: statusFilter || undefined,
+        targetPrinterId,
+        targetModelClass,
       }),
     refetchInterval: 15_000,
   });
@@ -78,6 +114,21 @@ export function PipelineRunsPage() {
       showToast(err.message || t('pipelineRuns.toast.retryFailed', 'Retry failed'), 'error'),
   });
 
+  const clearMutation = useMutation({
+    mutationFn: () => api.clearTerminalPipelineRuns(),
+    onSuccess: (result) => {
+      queryClient.invalidateQueries({ queryKey: ['pipeline-runs-all'] });
+      setShowClearConfirm(false);
+      setOffset(0);
+      showToast(
+        t('pipelineRuns.toast.cleared', '{{n}} runs cleared', { n: result.deleted }),
+        'success',
+      );
+    },
+    onError: (err: Error) =>
+      showToast(err.message || t('pipelineRuns.toast.clearFailed', 'Clear failed'), 'error'),
+  });
+
   const runs = runsList?.runs ?? [];
   const total = runsList?.total ?? 0;
   const pipelinesById: Record<number, SlicerPipeline> = (pipelines?.pipelines ?? []).reduce(
@@ -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 (
-    <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
           type="button"
           onClick={() => queryClient.invalidateQueries({ queryKey: ['pipeline-runs-all'] })}
           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>
-      </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);
-              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);
-              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>
 
+      {/* Clear-log confirmation modal. Only deletes terminal runs (completed
+          / failed / cancelled / partial_failure); in-flight runs are
+          preserved so an active batch isn't accidentally torched. */}
+      {showClearConfirm && (
+        <div
+          className="fixed inset-0 z-50 bg-black/60 flex items-center justify-center p-4"
+          onClick={() => setShowClearConfirm(false)}
+          role="dialog"
+          aria-modal="true"
+        >
+          <div
+            className="bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-2xl w-full max-w-md p-4"
+            onClick={(e) => e.stopPropagation()}
+          >
+            <h3 className="text-base font-semibold text-white flex items-center gap-2">
+              <Trash2 className="w-4 h-4 text-red-400" />
+              {t('pipelineRuns.clearConfirmTitle', 'Clear log?')}
+            </h3>
+            <p className="text-sm text-bambu-gray mt-2">
+              {t(
+                'pipelineRuns.clearConfirmBody',
+                'Delete every completed, failed, cancelled, and partial-failure pipeline run? In-flight runs are kept. This cannot be undone.',
+              )}
+            </p>
+            <div className="flex items-center justify-end gap-2 mt-4">
+              <button
+                type="button"
+                onClick={() => setShowClearConfirm(false)}
+                disabled={clearMutation.isPending}
+                className="px-3 py-1.5 text-sm text-bambu-gray hover:text-white"
+              >
+                {t('common.cancel', 'Cancel')}
+              </button>
+              <button
+                type="button"
+                onClick={() => clearMutation.mutate()}
+                disabled={clearMutation.isPending}
+                className="px-3 py-1.5 text-sm bg-red-500 hover:bg-red-600 text-white rounded disabled:opacity-50 flex items-center gap-1"
+              >
+                {clearMutation.isPending ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Trash2 className="w-3.5 h-3.5" />}
+                {t('pipelineRuns.clearConfirmAction', 'Clear')}
+              </button>
+            </div>
+          </div>
+        </div>
+      )}
+
       {isLoading && (
         <div className="flex items-center gap-2 text-bambu-gray">
           <Loader2 className="w-4 h-4 animate-spin" />
@@ -161,16 +330,21 @@ export function PipelineRunsPage() {
         </div>
       )}
       {!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 && (
-        <div className="space-y-2">
+        <div className="space-y-1.5">
           {runs.map((run) => (
             <RunRow
               key={run.id}
               run={run}
               pipeline={run.pipeline_id ? pipelinesById[run.pipeline_id] : undefined}
+              printersById={printersById}
               expanded={expanded.has(run.id)}
               onToggle={() => toggle(run.id)}
               onCancel={() => cancelMutation.mutate(run.id)}
@@ -216,6 +390,7 @@ export function PipelineRunsPage() {
 function RunRow({
   run,
   pipeline,
+  printersById,
   expanded,
   onToggle,
   onCancel,
@@ -225,6 +400,7 @@ function RunRow({
 }: {
   run: PipelineRun;
   pipeline: SlicerPipeline | undefined;
+  printersById: Record<number, Printer>;
   expanded: boolean;
   onToggle: () => void;
   onCancel: () => void;
@@ -235,48 +411,85 @@ function RunRow({
   const { t } = useTranslation();
   const inFlight = ['queued', 'slicing', 'dispatching', 'in_progress'].includes(run.status);
   const partial = run.status === 'partial_failure' || run.status === 'failed';
+  const userCancelled = run.status === 'cancelled' && run.error_message === 'Cancelled by user';
+
+  // Target chip — class targeting reads "Any X1C", specific-printer reads the
+  // printer's actual name (resolved from the printersById map). The chip is
+  // only rendered when we have a target to show; an unbound pipeline simply
+  // omits it.
+  const targetLabel = run.target_kind === 'printer_class' && run.target_model_class
+    ? t('library.runWithPipeline.classTarget', 'Any {{model}}', { model: run.target_model_class })
+    : run.target_printer_id && printersById[run.target_printer_id]
+      ? printersById[run.target_printer_id].name
+      : null;
 
   return (
-    <div className="rounded-md border border-bambu-dark-tertiary bg-bambu-dark/40">
+    <div className="rounded-md border border-bambu-dark-tertiary bg-bambu-dark/40 overflow-hidden">
       <div className="flex items-center gap-2 px-3 py-2">
         <button
           type="button"
           onClick={onToggle}
           aria-label={expanded ? t('common.collapse', 'Collapse') : t('common.expand', 'Expand')}
           aria-expanded={expanded}
-          className="text-bambu-gray hover:text-white"
+          className="text-bambu-gray hover:text-white flex-shrink-0"
         >
           {expanded ? <ChevronDown className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
         </button>
         <div className="flex-1 min-w-0">
-          <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>
             <RunStatusChip status={run.status} />
+            {targetLabel && (
+              <span className="text-xs px-1.5 py-0.5 rounded bg-bambu-dark-tertiary text-bambu-gray flex items-center gap-1">
+                <PrinterIcon className="w-3 h-3" />
+                {targetLabel}
+              </span>
+            )}
             {run.parent_run_id && (
-              <span className="text-xs text-bambu-gray/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>
             )}
           </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 && (
               <>
-                {' '}· {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>
-                /{run.copies}
                 {run.copies_failed > 0 && (
                   <>
-                    {' '}·{' '}
+                    <span className="text-bambu-gray/40">·</span>
                     <span className="text-red-400">
                       {t('pipelineRuns.failedCount', '{{n}} failed', { n: run.copies_failed })}
                     </span>
@@ -286,14 +499,14 @@ function RunRow({
             )}
           </div>
         </div>
-        <div className="flex items-center gap-1">
+        <div className="flex items-center gap-1 flex-shrink-0">
           {inFlight && (
             <button
               type="button"
               onClick={onCancel}
               disabled={cancelling}
               aria-label={t('common.cancel', 'Cancel')}
-              className="text-xs px-2 py-1 text-red-400 hover:bg-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" />
               {t('common.cancel', 'Cancel')}
@@ -305,7 +518,7 @@ function RunRow({
               onClick={onRetry}
               disabled={retrying}
               aria-label={t('pipelineRuns.retryFailed', 'Retry failed')}
-              className="text-xs px-2 py-1 text-bambu-green hover:bg-bambu-dark-tertiary rounded disabled:opacity-50 flex items-center gap-1"
+              className="text-xs px-2 py-1 text-bambu-green hover:bg-bambu-green/10 rounded disabled:opacity-50 flex items-center gap-1"
             >
               <RotateCcw className="w-3 h-3" />
               {t('pipelineRuns.retryFailed', 'Retry failed')}
@@ -314,64 +527,189 @@ function RunRow({
         </div>
       </div>
       {expanded && (
-        <div className="border-t border-bambu-dark-tertiary px-3 py-2 space-y-1">
-          {run.jobs.map((job) => (
-            <div key={job.id} className="flex items-center gap-2 text-xs text-bambu-gray">
-              <span className="text-bambu-gray/60 w-12">
-                {t('pipelineRuns.copyN', 'Copy {{n}}', { n: job.copy_index + 1 })}
-              </span>
-              <JobStatusChip status={job.status} />
-              {job.assigned_printer_name && (
-                <span className="text-white">{job.assigned_printer_name}</span>
-              )}
-              {job.error_message && (
-                <span className="text-red-400 truncate">{job.error_message}</span>
-              )}
-            </div>
-          ))}
-          {run.error_message && (
-            <div className="text-xs text-red-400 mt-1">
+        <div className="border-t border-bambu-dark-tertiary px-3 py-2 bg-bambu-dark/30">
+          <div className="space-y-1.5">
+            {run.jobs.map((job) => (
+              <div key={job.id} className="flex items-center gap-2 text-xs">
+                <span className="text-bambu-gray/60 w-14 flex-shrink-0">
+                  {t('pipelineRuns.copyN', 'Copy {{n}}', { n: job.copy_index + 1 })}
+                </span>
+                <JobStatusChip status={job.status} />
+                {job.assigned_printer_name && (
+                  <span className="text-bambu-gray flex items-center gap-1 truncate">
+                    <PrinterIcon className="w-3 h-3 text-bambu-gray/60" />
+                    <span className="text-white truncate">{job.assigned_printer_name}</span>
+                  </span>
+                )}
+                {job.error_message && (
+                  <span className="text-red-400 truncate" title={job.error_message}>
+                    {job.error_message}
+                  </span>
+                )}
+              </div>
+            ))}
+          </div>
+          {run.error_message && !userCancelled && (
+            <div className="text-xs text-red-400 mt-2 pt-2 border-t border-bambu-dark-tertiary">
               {run.error_message}
             </div>
           )}
+          {userCancelled && (
+            <div className="text-xs text-bambu-gray/70 mt-2 pt-2 border-t border-bambu-dark-tertiary italic">
+              {t('pipelineRuns.cancelledByUser', 'Cancelled by user')}
+            </div>
+          )}
         </div>
       )}
     </div>
   );
 }
 
+// High-contrast badges on solid Tailwind palette colours. The previous
+// version used ``bg-bambu-gray/25 text-bambu-gray`` etc. — same hue for
+// background AND text — which made every chip look washed-out grey
+// regardless of state. These use saturated 700-tier backgrounds with bright
+// 100-tier text, so each state reads at a glance.
 function RunStatusChip({ status }: { status: PipelineRun['status'] }) {
   const { t } = useTranslation();
   const colours: Record<PipelineRun['status'], string> = {
-    queued: 'bg-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 (
-    <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)}
     </span>
   );
 }
 
+// Custom dropdown — bambu-themed replacement for the native browser
+// `<select>` element. Same value/onChange contract; supports optional
+// `group` per option to render section headers (replaces <optgroup>).
+// Closes on outside click and Escape.
+function FilterDropdown({
+  value,
+  onChange,
+  options,
+  icon,
+  ariaLabel,
+}: {
+  value: string;
+  onChange: (value: string) => void;
+  options: DropdownOption[];
+  icon: ReactNode;
+  ariaLabel: string;
+}) {
+  const [open, setOpen] = useState(false);
+  const ref = useRef<HTMLDivElement>(null);
+
+  useEffect(() => {
+    if (!open) return;
+    const handleMouseDown = (e: MouseEvent) => {
+      if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
+    };
+    const handleKey = (e: KeyboardEvent) => {
+      if (e.key === 'Escape') setOpen(false);
+    };
+    document.addEventListener('mousedown', handleMouseDown);
+    document.addEventListener('keydown', handleKey);
+    return () => {
+      document.removeEventListener('mousedown', handleMouseDown);
+      document.removeEventListener('keydown', handleKey);
+    };
+  }, [open]);
+
+  const selected = options.find((o) => o.value === value);
+
+  // Group consecutive options that share the same `group` so the menu can
+  // render one header per group (mirrors the <optgroup> shape).
+  const grouped: { group: string | undefined; options: DropdownOption[] }[] = [];
+  for (const opt of options) {
+    const last = grouped[grouped.length - 1];
+    if (last && last.group === opt.group) last.options.push(opt);
+    else grouped.push({ group: opt.group, options: [opt] });
+  }
+
+  return (
+    <div ref={ref} className="relative">
+      <button
+        type="button"
+        onClick={() => setOpen((v) => !v)}
+        aria-label={ariaLabel}
+        aria-haspopup="listbox"
+        aria-expanded={open}
+        className="flex items-center gap-1.5 px-2 py-1 border border-bambu-dark-tertiary rounded bg-bambu-dark/40 text-xs text-white hover:border-bambu-gray/60 focus:outline-none focus:ring-1 focus:ring-bambu-green/40"
+      >
+        {icon}
+        <span className="truncate max-w-[14rem]">{selected?.label ?? ''}</span>
+        <ChevronDown
+          className={`w-3 h-3 text-bambu-gray transition-transform ${open ? 'rotate-180' : ''}`}
+        />
+      </button>
+      {open && (
+        <div
+          role="listbox"
+          className="absolute left-0 top-full mt-1 z-30 min-w-full max-h-72 overflow-auto rounded border border-bambu-dark-tertiary bg-bambu-dark-secondary shadow-xl py-1"
+        >
+          {grouped.map((g, gi) => (
+            <div key={gi}>
+              {g.group && (
+                <div className="px-2 pt-1.5 pb-0.5 text-[10px] uppercase tracking-wider text-bambu-gray/60">
+                  {g.group}
+                </div>
+              )}
+              {g.options.map((opt) => {
+                const isSelected = opt.value === value;
+                return (
+                  <button
+                    key={opt.value}
+                    type="button"
+                    role="option"
+                    aria-selected={isSelected}
+                    onClick={() => {
+                      onChange(opt.value);
+                      setOpen(false);
+                    }}
+                    className={`flex w-full items-center gap-1.5 text-left px-2 py-1 text-xs whitespace-nowrap ${
+                      isSelected
+                        ? 'bg-bambu-dark-tertiary text-white'
+                        : 'text-bambu-gray hover:bg-bambu-dark-tertiary/60 hover:text-white'
+                    }`}
+                  >
+                    <Check
+                      className={`w-3 h-3 flex-shrink-0 ${isSelected ? 'opacity-100' : 'opacity-0'}`}
+                    />
+                    <span className="truncate">{opt.label}</span>
+                  </button>
+                );
+              })}
+            </div>
+          ))}
+        </div>
+      )}
+    </div>
+  );
+}
+
 function JobStatusChip({ status }: { status: PipelineRun['jobs'][number]['status'] }) {
   const { t } = useTranslation();
   const colours: Record<PipelineRun['jobs'][number]['status'], string> = {
-    pending: '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 (
-    <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)}
     </span>
   );

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

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

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 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 -->
     <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>
   <body>
     <div id="root"></div>

이 변경점에서 너무 많은 파일들이 변경되어 몇몇 파일들은 표시되지 않았습니다.