Parcourir la source

feat(slicer): Slicer Pipelines — save & reuse a preset bundle in one click (#1425 PR A)

The SliceModal forces the user to pick four slots every time (printer /
process / filament(s) / bed type). For fleet production that's tedious
and error-prone. Pipelines let an operator save a named bundle and apply
it with one click on the next file.

PR A is bundle-and-management only. PR B adds single-target dispatch,
PR C adds multi-copy batch with capability-matched fanout. Future-PR
columns (target_kind / target_printer_id / target_model_class /
fanout_strategy) ship in this migration so PR B+ is code-only, not a
schema bump.

Backend
- New model SlicerPipeline + slicer_pipelines table; soft-delete via
  is_deleted so PR B+ run history can still resolve metadata.
- Pydantic schemas reuse the existing PresetRef shape from
  schemas/slicer.py.
- CRUD routes at /api/v1/slicer-pipelines/ — list (newest first by id
  DESC), create (201), get-by-id, partial PUT, soft-delete (204).
- Three new permissions: PIPELINES_READ / PIPELINES_WRITE / PIPELINES_RUN.
  Administrators + Operators get all three; Viewers get READ.
  Backfill in seed_default_groups() so existing installs upgrade
  cleanly. All three denied to API keys for now.

Frontend
- Settings → Workflow splits into two horizontal sub-tabs mirroring
  the Authentication tab pattern: "Queue & Dispatch" (existing
  Workflow content) and "Pipelines" (new). URL deep-link via
  ?tab=queue&sub=pipelines.
- SlicerPipelinesPanel — list, inline rename, delete, stale-preset
  warning when a referenced preset no longer resolves.
- SliceModal gets "Apply pipeline ▾" + "Save as pipeline". Apply
  fills all four slot states; the filament list right-pads from
  current state so a pipeline with fewer entries than the current
  source's slot count keeps the existing tail.
maziggy il y a 2 mois
Parent
commit
d6bdb7e200

Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
CHANGELOG.md


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

@@ -0,0 +1,177 @@
+"""API routes for Slicer Pipelines (#1425, PR A — definitions only).
+
+A pipeline bundles printer / process / filament(s) / bed-type picks so the
+SliceModal can apply them in one click. PR A surfaces only CRUD + an
+``apply`` helper that returns the pipeline as the four ``PresetRef`` slots a
+``SliceRequest`` expects. PR B adds single-target dispatch; PR C adds
+multi-copy fanout and the run dashboard.
+"""
+
+import json
+import logging
+
+from fastapi import APIRouter, Depends, HTTPException
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.core.auth import RequirePermissionIfAuthEnabled
+from backend.app.core.database import get_db
+from backend.app.core.permissions import Permission
+from backend.app.models.slicer_pipeline import SlicerPipeline
+from backend.app.models.user import User
+from backend.app.schemas.slicer import PresetRef
+from backend.app.schemas.slicer_pipeline import (
+    SlicerPipelineCreate,
+    SlicerPipelineListResponse,
+    SlicerPipelineResponse,
+    SlicerPipelineUpdate,
+)
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter(prefix="/slicer-pipelines", tags=["Slicer Pipelines"])
+
+
+def _to_response(row: SlicerPipeline) -> SlicerPipelineResponse:
+    """Materialise the JSON filament list back into PresetRef objects so the
+    response shape matches the create/update input shape exactly."""
+    try:
+        raw = json.loads(row.filament_presets_json) if row.filament_presets_json else []
+    except (json.JSONDecodeError, TypeError):
+        # Row was hand-edited or corrupted — return an empty list rather than
+        # 500ing on a list endpoint. Edit/run paths will surface the problem.
+        logger.warning("slicer_pipeline %d has invalid filament_presets_json", row.id)
+        raw = []
+    filament_presets = [PresetRef(**f) for f in raw if isinstance(f, dict)]
+
+    return SlicerPipelineResponse(
+        id=row.id,
+        name=row.name,
+        description=row.description,
+        printer_preset=PresetRef(source=row.printer_preset_source, id=row.printer_preset_id),
+        process_preset=PresetRef(source=row.process_preset_source, id=row.process_preset_id),
+        filament_presets=filament_presets,
+        bed_type=row.bed_type,
+        target_kind=row.target_kind,  # type: ignore[arg-type]
+        target_printer_id=row.target_printer_id,
+        target_model_class=row.target_model_class,
+        fanout_strategy=row.fanout_strategy,  # type: ignore[arg-type]
+        created_by=row.created_by,
+        created_at=row.created_at,
+        updated_at=row.updated_at,
+    )
+
+
+@router.get("/", response_model=SlicerPipelineListResponse)
+async def list_pipelines(
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_READ),
+    db: AsyncSession = Depends(get_db),
+):
+    """List all pipelines, newest first. Soft-deleted rows are hidden."""
+    result = await db.execute(
+        select(SlicerPipeline).where(SlicerPipeline.is_deleted.is_(False)).order_by(SlicerPipeline.id.desc())
+    )
+    rows = result.scalars().all()
+    return SlicerPipelineListResponse(pipelines=[_to_response(r) for r in rows])
+
+
+@router.post("/", response_model=SlicerPipelineResponse, status_code=201)
+async def create_pipeline(
+    data: SlicerPipelineCreate,
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_WRITE),
+    db: AsyncSession = Depends(get_db),
+):
+    """Create a new pipeline."""
+    row = SlicerPipeline(
+        name=data.name.strip(),
+        description=data.description,
+        printer_preset_source=data.printer_preset.source,
+        printer_preset_id=data.printer_preset.id,
+        process_preset_source=data.process_preset.source,
+        process_preset_id=data.process_preset.id,
+        filament_presets_json=json.dumps([f.model_dump() for f in data.filament_presets]),
+        bed_type=data.bed_type,
+        created_by=current_user.id if current_user else None,
+    )
+    db.add(row)
+    await db.commit()
+    await db.refresh(row)
+    return _to_response(row)
+
+
+@router.get("/{pipeline_id}", response_model=SlicerPipelineResponse)
+async def get_pipeline(
+    pipeline_id: int,
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_READ),
+    db: AsyncSession = Depends(get_db),
+):
+    """Read one pipeline by id."""
+    result = await db.execute(
+        select(SlicerPipeline).where(
+            SlicerPipeline.id == pipeline_id,
+            SlicerPipeline.is_deleted.is_(False),
+        )
+    )
+    row = result.scalar_one_or_none()
+    if not row:
+        raise HTTPException(404, "Pipeline not found")
+    return _to_response(row)
+
+
+@router.put("/{pipeline_id}", response_model=SlicerPipelineResponse)
+async def update_pipeline(
+    pipeline_id: int,
+    data: SlicerPipelineUpdate,
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_WRITE),
+    db: AsyncSession = Depends(get_db),
+):
+    """Update a pipeline. Only fields present in the payload are written."""
+    result = await db.execute(
+        select(SlicerPipeline).where(
+            SlicerPipeline.id == pipeline_id,
+            SlicerPipeline.is_deleted.is_(False),
+        )
+    )
+    row = result.scalar_one_or_none()
+    if not row:
+        raise HTTPException(404, "Pipeline not found")
+
+    if data.name is not None:
+        row.name = data.name.strip()
+    if data.description is not None:
+        row.description = data.description
+    if data.printer_preset is not None:
+        row.printer_preset_source = data.printer_preset.source
+        row.printer_preset_id = data.printer_preset.id
+    if data.process_preset is not None:
+        row.process_preset_source = data.process_preset.source
+        row.process_preset_id = data.process_preset.id
+    if data.filament_presets is not None:
+        row.filament_presets_json = json.dumps([f.model_dump() for f in data.filament_presets])
+    if data.bed_type is not None:
+        row.bed_type = data.bed_type
+
+    await db.commit()
+    await db.refresh(row)
+    return _to_response(row)
+
+
+@router.delete("/{pipeline_id}", status_code=204)
+async def delete_pipeline(
+    pipeline_id: int,
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_WRITE),
+    db: AsyncSession = Depends(get_db),
+):
+    """Soft-delete a pipeline (sets is_deleted=True so PR B+ run history can
+    still resolve pipeline metadata)."""
+    result = await db.execute(
+        select(SlicerPipeline).where(
+            SlicerPipeline.id == pipeline_id,
+            SlicerPipeline.is_deleted.is_(False),
+        )
+    )
+    row = result.scalar_one_or_none()
+    if not row:
+        raise HTTPException(404, "Pipeline not found")
+    row.is_deleted = True
+    await db.commit()

+ 7 - 0
backend/app/core/auth.py

@@ -222,6 +222,13 @@ _APIKEY_DENIED_PERMISSIONS: frozenset[Permission] = frozenset(
         Permission.SMART_PLUGS_DELETE,
         # Network scanning — operator only (no API-key scope for this).
         Permission.DISCOVERY_SCAN,
+        # Slicer Pipelines (#1425) — admin authoring + the print-spending Run
+        # action. PR A only ships CRUD; PR B / PR C may move PIPELINES_RUN onto
+        # `can_queue` (it queues prints) once the run dispatch lands. PR A keeps
+        # all three denied so they fail closed for any API-key surface.
+        Permission.PIPELINES_READ,
+        Permission.PIPELINES_WRITE,
+        Permission.PIPELINES_RUN,
     }
 )
 

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

@@ -198,6 +198,7 @@ async def init_db():
         project_bom,
         settings,
         shopping_list,
+        slicer_pipeline,
         slot_preset,
         smart_plug,
         smart_plug_energy_snapshot,
@@ -3594,6 +3595,38 @@ 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)
+        #   - Operators: all three (matches fresh-install DEFAULT_GROUPS)
+        #   - Viewers + any 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:
+                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":
+                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 Operators group (backfill)", new_perm)
+            elif "pipelines:read" not in perms and ("library:read_own" in perms or "settings:read" in perms):
+                perms.append("pipelines:read")
+                changed = True
+                logger.info("Added pipelines:read to group '%s' (backfill)", group.name)
+            if changed:
+                group.permissions = perms
+        await session.commit()
+
         # Migrate existing users to groups if they're not already in any group
         if groups_created:
             # Refresh to get newly created groups

+ 16 - 0
backend/app/core/permissions.py

@@ -178,6 +178,11 @@ class Permission(StrEnum):
     GROUPS_UPDATE = "groups:update"
     GROUPS_DELETE = "groups:delete"
 
+    # Slicer Pipelines (#1425)
+    PIPELINES_READ = "pipelines:read"  # View pipeline definitions and run history
+    PIPELINES_WRITE = "pipelines:write"  # Create / edit / delete pipeline definitions
+    PIPELINES_RUN = "pipelines:run"  # Kick off a pipeline run (PR C); separate because spending filament is a different trust dimension than authoring the recipe
+
     # WebSocket connection
     WEBSOCKET_CONNECT = "websocket:connect"
 
@@ -337,6 +342,11 @@ PERMISSION_CATEGORIES = {
         Permission.GROUPS_UPDATE,
         Permission.GROUPS_DELETE,
     ],
+    "Slicer Pipelines": [
+        Permission.PIPELINES_READ,
+        Permission.PIPELINES_WRITE,
+        Permission.PIPELINES_RUN,
+    ],
     "WebSocket": [
         Permission.WEBSOCKET_CONNECT,
     ],
@@ -452,6 +462,10 @@ DEFAULT_GROUPS = {
             Permission.SYSTEM_READ.value,
             # Settings - read only
             Permission.SETTINGS_READ.value,
+            # Slicer Pipelines - full access
+            Permission.PIPELINES_READ.value,
+            Permission.PIPELINES_WRITE.value,
+            Permission.PIPELINES_RUN.value,
             # WebSocket
             Permission.WEBSOCKET_CONNECT.value,
         ],
@@ -483,6 +497,8 @@ DEFAULT_GROUPS = {
             Permission.STATS_READ.value,
             Permission.SYSTEM_READ.value,
             Permission.SETTINGS_READ.value,
+            # Slicer Pipelines - read only
+            Permission.PIPELINES_READ.value,
             Permission.WEBSOCKET_CONNECT.value,
             # MakerWorld browsing only (no import — that writes to library)
             Permission.MAKERWORLD_VIEW.value,

+ 2 - 0
backend/app/main.py

@@ -56,6 +56,7 @@ from backend.app.api.routes import (
     projects,
     settings as settings_routes,
     slice_jobs,
+    slicer_pipelines,
     slicer_presets,
     smart_plugs,
     sponsor_prompt,
@@ -6750,6 +6751,7 @@ app.include_router(library.router, prefix=app_settings.api_prefix)
 app.include_router(library_tags.router, prefix=app_settings.api_prefix)
 app.include_router(library_trash.router, prefix=app_settings.api_prefix)
 app.include_router(slice_jobs.router, prefix=app_settings.api_prefix)
+app.include_router(slicer_pipelines.router, prefix=app_settings.api_prefix)
 app.include_router(slicer_presets.router, prefix=app_settings.api_prefix)
 app.include_router(archive_purge.router, prefix=app_settings.api_prefix)
 app.include_router(makerworld.router, prefix=app_settings.api_prefix)

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

@@ -23,6 +23,7 @@ from backend.app.models.printer import Printer
 from backend.app.models.printer_sensor_history import PrinterSensorHistory
 from backend.app.models.project import Project
 from backend.app.models.settings import Settings
+from backend.app.models.slicer_pipeline import SlicerPipeline
 from backend.app.models.smart_plug import SmartPlug
 from backend.app.models.smart_plug_energy_snapshot import SmartPlugEnergySnapshot
 from backend.app.models.sponsor_toast_state import SponsorToastState
@@ -69,6 +70,7 @@ __all__ = [
     "OIDCProvider",
     "UserOIDCLink",
     "OrcaBaseProfile",
+    "SlicerPipeline",
     "Spool",
     "SpoolKProfile",
     "SpoolAssignment",

+ 61 - 0
backend/app/models/slicer_pipeline.py

@@ -0,0 +1,61 @@
+"""Model for a Slicing/Printing Pipeline definition (#1425).
+
+A pipeline bundles the four slot picks a user normally makes in the SliceModal
+(printer / process / filament(s) / bed type) under a named, reusable preset.
+This is PR A — bundle definitions only. Run state and dispatch live in
+``pipeline_runs`` / ``pipeline_jobs`` (PR B + PR C).
+
+The target_* and fanout_strategy columns are materialised now to avoid a
+second migration when PR B / PR C land; PR A's API accepts the defaults and
+the UI doesn't expose them yet.
+"""
+
+from datetime import datetime
+
+from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, func
+from sqlalchemy.orm import Mapped, mapped_column
+
+from backend.app.core.database import Base
+
+
+class SlicerPipeline(Base):
+    """A named slicer preset bundle (printer + process + filament[s] + bed)."""
+
+    __tablename__ = "slicer_pipelines"
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+    name: Mapped[str] = mapped_column(String(200))
+    description: Mapped[str | None] = mapped_column(String(1000))
+
+    # Preset slots. ``*_source`` mirrors PresetRef.source semantics
+    # (orca_cloud / cloud / local / standard); ``*_id`` is the opaque
+    # source-specific id the slicer pipeline uses to resolve content.
+    printer_preset_source: Mapped[str] = mapped_column(String(20))
+    printer_preset_id: Mapped[str] = mapped_column(String(200))
+    process_preset_source: Mapped[str] = mapped_column(String(20))
+    process_preset_id: Mapped[str] = mapped_column(String(200))
+    # JSON array of {"source": ..., "id": ...} entries — one per AMS slot the
+    # source plate is expected to use. Stored as JSON text per Bambuddy's
+    # convention (see LocalPreset.compatible_printers).
+    filament_presets_json: Mapped[str] = mapped_column(Text)
+
+    bed_type: Mapped[str | None] = mapped_column(String(64))
+
+    # Target — PR B+ wiring; PR A treats every pipeline as a bundle without
+    # an active target. Kept materialised so PR B is code-only, not a
+    # migration. ``target_kind`` ∈ {"specific_printer", "printer_class"}.
+    target_kind: Mapped[str] = mapped_column(String(20), default="printer_class")
+    target_printer_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("printers.id", ondelete="SET NULL"))
+    target_model_class: Mapped[str | None] = mapped_column(String(20))
+
+    # Fanout strategy for PR C multi-copy runs. PR A defaults it; the UI
+    # doesn't expose it yet. Values: max_parallel / fill_one_first / round_robin.
+    fanout_strategy: Mapped[str] = mapped_column(String(20), default="max_parallel")
+
+    # Audit fields. created_by is nullable so pipelines survive user deletes
+    # and so installs without auth enabled (current_user is None) still work.
+    created_by: Mapped[int | None] = mapped_column(Integer, ForeignKey("users.id", ondelete="SET NULL"))
+    is_deleted: Mapped[bool] = mapped_column(Boolean, default=False, server_default="0")
+
+    created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
+    updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())

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

@@ -0,0 +1,72 @@
+"""Pydantic schemas for the Slicer Pipeline API (#1425, PR A).
+
+A pipeline bundles printer / process / filament(s) / bed-type picks under a
+reusable name. PR A surfaces only the bundle; target_kind / target_printer_id /
+target_model_class / fanout_strategy are persisted but the API treats them as
+opaque defaults — they come alive in PR B (single-target dispatch) and PR C
+(multi-copy + class targeting + fanout).
+"""
+
+from datetime import datetime
+from typing import Literal
+
+from pydantic import BaseModel, Field
+
+from backend.app.schemas.slicer import PresetRef
+
+
+class SlicerPipelineBase(BaseModel):
+    """Fields editable on create + update."""
+
+    name: str = Field(..., min_length=1, max_length=200)
+    description: str | None = Field(default=None, max_length=1000)
+
+    printer_preset: PresetRef
+    process_preset: PresetRef
+    filament_presets: list[PresetRef] = Field(
+        ...,
+        min_length=1,
+        description="One PresetRef per AMS slot. Order matches the source plate's filament-slot order.",
+    )
+    bed_type: str | None = Field(default=None, max_length=64)
+
+
+class SlicerPipelineCreate(SlicerPipelineBase):
+    """Payload for POST /slicer-pipelines."""
+
+
+class SlicerPipelineUpdate(BaseModel):
+    """Payload for PUT /slicer-pipelines/{id}. All fields optional; only those
+    present are written. Preset and filament list are replaced wholesale when
+    set (we don't support partial filament-slot edits)."""
+
+    name: str | None = Field(default=None, min_length=1, max_length=200)
+    description: str | None = Field(default=None, max_length=1000)
+    printer_preset: PresetRef | None = None
+    process_preset: PresetRef | None = None
+    filament_presets: list[PresetRef] | None = Field(default=None, min_length=1)
+    bed_type: str | None = Field(default=None, max_length=64)
+
+
+class SlicerPipelineResponse(SlicerPipelineBase):
+    """A single pipeline as returned by the API."""
+
+    id: int
+    created_by: int | None
+    created_at: datetime
+    updated_at: datetime
+
+    # Echoed for PR B+ readiness; PR A always returns the persisted defaults.
+    target_kind: Literal["specific_printer", "printer_class"] = "printer_class"
+    target_printer_id: int | None = None
+    target_model_class: str | None = None
+    fanout_strategy: Literal["max_parallel", "fill_one_first", "round_robin"] = "max_parallel"
+
+    model_config = {"from_attributes": True}
+
+
+class SlicerPipelineListResponse(BaseModel):
+    """Wraps the list so the response stays additive when run/job counts get
+    surfaced in PR B+ (e.g. a ``meta`` field for last-run timestamps)."""
+
+    pipelines: list[SlicerPipelineResponse] = []

+ 158 - 0
backend/tests/integration/test_slicer_pipelines_api.py

@@ -0,0 +1,158 @@
+"""Integration tests for the Slicer Pipelines API (#1425 PR A)."""
+
+import pytest
+from httpx import AsyncClient
+
+
+def _preset_ref(source: str, id_: str) -> dict:
+    return {"source": source, "id": id_}
+
+
+def _payload(**overrides) -> dict:
+    payload = {
+        "name": "Production Batch",
+        "description": "High speed PLA on X1C",
+        "printer_preset": _preset_ref("local", "42"),
+        "process_preset": _preset_ref("local", "7"),
+        "filament_presets": [_preset_ref("local", "11"), _preset_ref("standard", "PLA Basic")],
+        "bed_type": "Textured PEI Plate",
+    }
+    payload.update(overrides)
+    return payload
+
+
+class TestSlicerPipelinesAPI:
+    """CRUD + edge cases for /api/v1/slicer-pipelines."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_list_empty(self, async_client: AsyncClient):
+        """Empty list response uses the canonical {pipelines: []} envelope."""
+        resp = await async_client.get("/api/v1/slicer-pipelines/")
+        assert resp.status_code == 200
+        data = resp.json()
+        assert data == {"pipelines": []}
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_and_list(self, async_client: AsyncClient):
+        """A newly-created pipeline appears in the list with its full shape."""
+        resp = await async_client.post("/api/v1/slicer-pipelines/", json=_payload())
+        assert resp.status_code == 201, resp.text
+        created = resp.json()
+        assert created["name"] == "Production Batch"
+        assert created["printer_preset"] == _preset_ref("local", "42")
+        assert created["process_preset"] == _preset_ref("local", "7")
+        assert created["filament_presets"] == [
+            _preset_ref("local", "11"),
+            _preset_ref("standard", "PLA Basic"),
+        ]
+        assert created["bed_type"] == "Textured PEI Plate"
+        # PR A defaults persisted but not user-set
+        assert created["target_kind"] == "printer_class"
+        assert created["target_printer_id"] is None
+        assert created["fanout_strategy"] == "max_parallel"
+
+        list_resp = await async_client.get("/api/v1/slicer-pipelines/")
+        assert list_resp.status_code == 200
+        ids = [p["id"] for p in list_resp.json()["pipelines"]]
+        assert created["id"] in ids
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_by_id(self, async_client: AsyncClient):
+        """Round-trips the preset slots through JSON storage faithfully."""
+        created = (await async_client.post("/api/v1/slicer-pipelines/", json=_payload())).json()
+        resp = await async_client.get(f"/api/v1/slicer-pipelines/{created['id']}")
+        assert resp.status_code == 200
+        fetched = resp.json()
+        assert fetched["printer_preset"] == _preset_ref("local", "42")
+        assert fetched["filament_presets"] == [
+            _preset_ref("local", "11"),
+            _preset_ref("standard", "PLA Basic"),
+        ]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_not_found(self, async_client: AsyncClient):
+        resp = await async_client.get("/api/v1/slicer-pipelines/99999")
+        assert resp.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_partial(self, async_client: AsyncClient):
+        """PUT writes only fields that are present; others stay unchanged."""
+        created = (await async_client.post("/api/v1/slicer-pipelines/", json=_payload())).json()
+        resp = await async_client.put(
+            f"/api/v1/slicer-pipelines/{created['id']}",
+            json={"name": "Renamed", "bed_type": "Cool Plate"},
+        )
+        assert resp.status_code == 200
+        updated = resp.json()
+        assert updated["name"] == "Renamed"
+        assert updated["bed_type"] == "Cool Plate"
+        # Untouched fields preserved
+        assert updated["printer_preset"] == _preset_ref("local", "42")
+        assert updated["filament_presets"] == created["filament_presets"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_filament_list_replaces_wholesale(self, async_client: AsyncClient):
+        """Setting filament_presets replaces the entire list."""
+        created = (await async_client.post("/api/v1/slicer-pipelines/", json=_payload())).json()
+        new_filaments = [_preset_ref("cloud", "PFUS1"), _preset_ref("cloud", "PFUS2"), _preset_ref("cloud", "PFUS3")]
+        resp = await async_client.put(
+            f"/api/v1/slicer-pipelines/{created['id']}",
+            json={"filament_presets": new_filaments},
+        )
+        assert resp.status_code == 200
+        assert resp.json()["filament_presets"] == new_filaments
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_delete_is_soft(self, async_client: AsyncClient):
+        """DELETE hides from list + GET-by-id but doesn't drop the row (PR B+
+        run history must still resolve pipeline metadata)."""
+        created = (await async_client.post("/api/v1/slicer-pipelines/", json=_payload())).json()
+        resp = await async_client.delete(f"/api/v1/slicer-pipelines/{created['id']}")
+        assert resp.status_code == 204
+        # Hidden from list
+        list_resp = await async_client.get("/api/v1/slicer-pipelines/")
+        assert created["id"] not in [p["id"] for p in list_resp.json()["pipelines"]]
+        # Hidden from GET
+        get_resp = await async_client.get(f"/api/v1/slicer-pipelines/{created['id']}")
+        assert get_resp.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_delete_not_found(self, async_client: AsyncClient):
+        resp = await async_client.delete("/api/v1/slicer-pipelines/99999")
+        assert resp.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_rejects_empty_filament_list(self, async_client: AsyncClient):
+        """The schema requires at least one filament slot."""
+        payload = _payload(filament_presets=[])
+        resp = await async_client.post("/api/v1/slicer-pipelines/", json=payload)
+        assert resp.status_code == 422
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_rejects_invalid_preset_source(self, async_client: AsyncClient):
+        """PresetRef.source is constrained to the four known tiers."""
+        bad = _preset_ref("bogus_source", "1")
+        payload = _payload(printer_preset=bad)
+        resp = await async_client.post("/api/v1/slicer-pipelines/", json=payload)
+        assert resp.status_code == 422
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_list_orders_newest_first(self, async_client: AsyncClient):
+        first = (await async_client.post("/api/v1/slicer-pipelines/", json=_payload(name="First"))).json()
+        second = (await async_client.post("/api/v1/slicer-pipelines/", json=_payload(name="Second"))).json()
+        third = (await async_client.post("/api/v1/slicer-pipelines/", json=_payload(name="Third"))).json()
+        listing = (await async_client.get("/api/v1/slicer-pipelines/")).json()["pipelines"]
+        # Filter to the three we just made (DB may have other rows from other tests)
+        ours = [p for p in listing if p["id"] in {first["id"], second["id"], third["id"]}]
+        assert [p["name"] for p in ours] == ["Third", "Second", "First"]

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

@@ -147,6 +147,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)
   'Region', 'Normal', 'Orange', 'Branch', 'Budget', 'Commit', 'Global',
   'Version', 'Slot', 'Live', 'Rate', 'Host', 'Trend', 'Min', 'Admin', 'Cloud',
   'Filament', 'Filaments', 'Software', 'Hardware', 'Avatar', 'Pin', 'Modal',
@@ -179,6 +180,7 @@ const FR_COGNATES = [
   'Filaments', 'Software', 'Hardware', 'Stop', 'Reset', 'Test', 'Code',
   'Token', 'Server', 'Port', 'Plate', 'Layer', 'Active', 'Total', 'Avatar',
   'Job', 'Modal', 'Pin', 'Pro', 'Mini', 'Studio', 'Excellent', 'Description',
+  'Pipeline', 'Pipelines', 'Filament {{n}}',  // #1425 — Slicer Pipelines (FR)
   'Action', 'Actions', 'Date', 'Type', 'Cache', 'Service', 'Configuration',
   'Archives', 'Maintenance', 'Notifications', 'Notification', 'Position',
   'Pause', 'Solution', 'Source', 'Version', 'Format', 'Documentation',
@@ -217,6 +219,7 @@ const IT_COGNATES = [
   'Bambu Cloud', 'Orca Cloud',  // brand names — same in every locale
   'AMS Filament Backup',  // Bambu Lab product/firmware feature name
   'Email',  // common loanword in Italian, used verbatim in UI labels
+  'Pipeline',  // #1425 — Slicer Pipelines (cognate in IT)
   'Status', 'Tag', 'Tags', 'Online', 'Offline', 'Standard', 'Filament',
   'Filaments', 'Software', 'Hardware', 'Stop', 'Reset', 'Test', 'Code',
   'Token', 'Server', 'Port', 'Plate', 'Layer', 'Modal', 'Pin', 'Pro', 'Mini',
@@ -260,6 +263,7 @@ const JA_COGNATES = [
 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)
   'Status', 'Tag', 'Tags', 'Online', 'Offline', 'Standard', 'Filament',
   'Software', 'Hardware', 'Stop', 'Reset', 'Test', 'Code', 'Token', 'Server',
   'Port', 'Plate', 'Layer', 'Modal', 'Pin', 'Pro', 'Mini', 'Studio', 'Cache',
@@ -332,6 +336,7 @@ const KO_COGNATES = [
 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)
   'Error', 'Firmware', 'General', 'Control', 'Total', 'total', 'Material',
   'Material:', 'Color', 'Hex', 'Local', 'Global', 'China', 'Editable',
   'Normal', 'Metal', 'Multicolor', 'Proxy', 'Host', 'Factor', 'Original',
@@ -353,6 +358,7 @@ const TR_COGNATES = [
   'Filament', 'Firmware', 'Disk', 'Hex', 'Test', 'Port', 'Model', 'Metal',
   'Bambu Cloud', 'Orca Cloud',  // brand names — same in every locale
   'AMS Filament Backup',  // Bambu Lab product/firmware feature name
+  'Pipeline', 'Filament {{n}}',  // #1425 — Slicer Pipelines (TR)
   'Min', 'Normal', 'Platform', 'Net', 'Trend', 'Commit', 'Global', 'Proxy',
   'N/A', 'email',
   'STARTTLS (Port 587)', 'SSL/TLS (Port 465)',

+ 150 - 9
frontend/src/__tests__/components/SliceModal.test.tsx

@@ -28,6 +28,9 @@ vi.mock('../../api/client', () => ({
     getArchiveFilamentRequirements: vi.fn(),
     getSettings: vi.fn().mockResolvedValue({}),
     updateSettings: vi.fn().mockResolvedValue({}),
+    // Slicer Pipelines (#1425)
+    listSlicerPipelines: vi.fn(),
+    createSlicerPipeline: vi.fn(),
   },
 }));
 
@@ -40,6 +43,8 @@ const mockApi = api as unknown as {
   getArchivePlates: ReturnType<typeof vi.fn>;
   getLibraryFileFilamentRequirements: ReturnType<typeof vi.fn>;
   getArchiveFilamentRequirements: ReturnType<typeof vi.fn>;
+  listSlicerPipelines: ReturnType<typeof vi.fn>;
+  createSlicerPipeline: ReturnType<typeof vi.fn>;
 };
 
 function makeUnified(overrides: Partial<UnifiedPresetsResponse> = {}): UnifiedPresetsResponse {
@@ -80,6 +85,16 @@ function renderWithTracker(props: Parameters<typeof SliceModal>[0]) {
   );
 }
 
+// SliceModal renders one extra combobox for the Slicer Pipelines (#1425)
+// "Apply pipeline" dropdown above the preset slots. Tests written before
+// pipelines landed assume selects[0] = printer; this helper drops the
+// pipeline combobox so those indices stay stable.
+function presetSelects(): HTMLSelectElement[] {
+  return (screen.getAllByRole('combobox') as HTMLSelectElement[]).filter(
+    (el) => el.getAttribute('aria-label') !== 'Apply pipeline',
+  );
+}
+
 describe('SliceModal', () => {
   beforeEach(() => {
     vi.clearAllMocks();
@@ -121,6 +136,8 @@ describe('SliceModal', () => {
       plate_id: 1,
       filaments: [],
     });
+    // Default: no saved pipelines. Tests opt in by overriding this.
+    mockApi.listSlicerPipelines.mockResolvedValue({ pipelines: [] });
   });
 
   it('auto-selects the highest-priority tier per slot on first load', async () => {
@@ -137,7 +154,7 @@ describe('SliceModal', () => {
     // 4 selects: printer, process, bed-type (#1337), filament. bed-type sits
     // between process and filament — it overrides curr_bed_type on the
     // process preset so the related controls cluster — and defaults to "".
-    const selects = screen.getAllByRole('combobox') as HTMLSelectElement[];
+    const selects = presetSelects();
     expect(selects).toHaveLength(4);
     expect(selects[0].value).toBe('local:1');
     expect(selects[1].value).toBe('local:2');
@@ -158,7 +175,7 @@ describe('SliceModal', () => {
 
     await waitFor(() => expect(screen.getByText('Imported X1C 0.4')).toBeDefined());
 
-    const printerSelect = screen.getAllByRole('combobox')[0];
+    const printerSelect = presetSelects()[0];
     const groups = printerSelect.querySelectorAll('optgroup');
     expect(Array.from(groups).map((g) => g.label)).toEqual([
       'Imported',
@@ -190,7 +207,7 @@ describe('SliceModal', () => {
     });
 
     await waitFor(() => expect(screen.getByText('Imported X1C 0.4')).toBeDefined());
-    const selects = screen.getAllByRole('combobox') as HTMLSelectElement[];
+    const selects = presetSelects();
     expect(selects[0].value).toBe('local:1');
   });
 
@@ -204,7 +221,7 @@ describe('SliceModal', () => {
     });
 
     await waitFor(() => expect(screen.getByText('Bambu Lab X1 Carbon 0.4 nozzle')).toBeDefined());
-    const selects = screen.getAllByRole('combobox') as HTMLSelectElement[];
+    const selects = presetSelects();
     expect(selects[0].value).toBe('standard:Bambu Lab X1 Carbon 0.4 nozzle');
   });
 
@@ -260,7 +277,7 @@ describe('SliceModal', () => {
     // printer (0), process (1), bed-type (2), filament (3+). Find the
     // bed-type select by name rather than positional index so this stays
     // green if the layout adds another control around it.
-    const bedSelect = screen.getAllByRole('combobox').find((el) =>
+    const bedSelect = presetSelects().find((el) =>
       (el as HTMLSelectElement).options[0]?.textContent?.toLowerCase().includes('auto'),
     ) as HTMLSelectElement;
     expect(bedSelect).toBeDefined();
@@ -315,7 +332,7 @@ describe('SliceModal', () => {
     await waitFor(() => expect(screen.getByText('My Custom X1C')).toBeDefined());
 
     const user = userEvent.setup();
-    const selects = screen.getAllByRole('combobox');
+    const selects = presetSelects();
     await user.selectOptions(selects[0], 'standard:Bambu Lab X1 Carbon 0.4 nozzle');
     await user.click(screen.getByRole('button', { name: /^Slice$/ }));
 
@@ -731,7 +748,7 @@ describe('SliceModal', () => {
 
     await waitFor(() => expect(screen.getByText('X1C')).toBeDefined());
     // 1 printer + 1 process + 2 filament + 1 bed-type (#1337) = 5 dropdowns.
-    expect(screen.getAllByRole('combobox')).toHaveLength(5);
+    expect(presetSelects()).toHaveLength(5);
   });
 
   it('pre-picks each filament slot by matching colour metadata', async () => {
@@ -812,7 +829,7 @@ describe('SliceModal', () => {
     await waitFor(() => expect(screen.getByText('X1C')).toBeDefined());
 
     const user = userEvent.setup();
-    const selects = screen.getAllByRole('combobox') as HTMLSelectElement[];
+    const selects = presetSelects();
     // Order: 0 printer, 1 process, 2 bed-type, 3 filament-1, 4 filament-2
     // (#1337). Auto-picks land on printer/process/filaments; bed-type
     // defaults to "". Swap filament-1 (index 3) from the auto-picked black
@@ -937,7 +954,7 @@ describe('SliceModal', () => {
     // Both filament rows render — 1 printer + 1 process + 1 bed-type +
     // 2 filament (#1337) = 5. bed-type sits at index 2, filament slots
     // follow at 3 and 4.
-    const selects = screen.getAllByRole('combobox') as HTMLSelectElement[];
+    const selects = presetSelects();
     expect(selects).toHaveLength(5);
     // Slot 1 (used) is editable, slot 2 (not used) is disabled.
     expect(selects[3].disabled).toBe(false);
@@ -1018,4 +1035,128 @@ describe('SliceModal', () => {
     });
   });
 
+  // ------------------------------------------------------------------
+  // Slicer Pipelines (#1425) — Apply / Save integration in SliceModal
+  // ------------------------------------------------------------------
+
+  it('Apply pipeline dropdown is disabled and shows empty hint when no pipelines exist', async () => {
+    mockApi.listSlicerPipelines.mockResolvedValue({ pipelines: [] });
+    renderWithTracker({
+      source: { kind: 'libraryFile', id: 100, filename: 'Cube.stl' },
+      onClose: vi.fn(),
+    });
+    await waitFor(() => {
+      const select = screen.getByLabelText(/Apply pipeline/i) as HTMLSelectElement;
+      expect(select.disabled).toBe(true);
+      expect(select.querySelector('option')?.textContent).toMatch(/No saved pipelines/i);
+    });
+  });
+
+  it('applies a saved pipeline to printer, process, and bed_type slots on selection', async () => {
+    mockApi.listSlicerPipelines.mockResolvedValue({
+      pipelines: [
+        {
+          id: 7,
+          name: 'Production Batch',
+          description: null,
+          printer_preset: { source: 'local', id: '1' },
+          process_preset: { source: 'local', id: '2' },
+          filament_presets: [{ source: 'local', id: '3' }],
+          bed_type: 'Textured PEI Plate',
+          target_kind: 'printer_class',
+          target_printer_id: null,
+          target_model_class: null,
+          fanout_strategy: 'max_parallel',
+          created_by: null,
+          created_at: '2026-06-27T00:00:00Z',
+          updated_at: '2026-06-27T00:00:00Z',
+        },
+      ],
+    });
+
+    renderWithTracker({
+      source: { kind: 'libraryFile', id: 100, filename: 'Cube.stl' },
+      onClose: vi.fn(),
+    });
+
+    // Wait for presets + pipelines listing to populate the modal.
+    await waitFor(() => {
+      const select = screen.getByLabelText(/Apply pipeline/i) as HTMLSelectElement;
+      expect(select.disabled).toBe(false);
+      expect(within(select).getByText('Production Batch')).toBeDefined();
+    });
+
+    const user = userEvent.setup();
+    await user.selectOptions(screen.getByLabelText(/Apply pipeline/i), '7');
+
+    // After applying, submitting the slice request should carry the
+    // pipeline's preset refs end-to-end.
+    mockApi.sliceLibraryFile.mockResolvedValue({
+      job_id: 42,
+      status: 'queued',
+      status_url: '/api/v1/slice-jobs/42',
+    });
+
+    await user.click(screen.getByRole('button', { name: /^Slice$/ }));
+
+    await waitFor(() => {
+      expect(mockApi.sliceLibraryFile).toHaveBeenCalled();
+      const [, body] = mockApi.sliceLibraryFile.mock.calls[0];
+      expect(body.printer_preset).toEqual({ source: 'local', id: '1' });
+      expect(body.process_preset).toEqual({ source: 'local', id: '2' });
+      expect(body.filament_presets[0]).toEqual({ source: 'local', id: '3' });
+      expect(body.bed_type).toBe('Textured PEI Plate');
+    });
+  });
+
+  it('saves the current four-slot selection as a new pipeline when the user clicks Save as pipeline', async () => {
+    mockApi.listSlicerPipelines.mockResolvedValue({ pipelines: [] });
+    mockApi.createSlicerPipeline.mockResolvedValue({
+      id: 99,
+      name: 'My Default',
+      description: null,
+      printer_preset: { source: 'local', id: '1' },
+      process_preset: { source: 'local', id: '2' },
+      filament_presets: [{ source: 'local', id: '3' }],
+      bed_type: null,
+      target_kind: 'printer_class',
+      target_printer_id: null,
+      target_model_class: null,
+      fanout_strategy: 'max_parallel',
+      created_by: null,
+      created_at: '2026-06-27T00:00:00Z',
+      updated_at: '2026-06-27T00:00:00Z',
+    });
+
+    renderWithTracker({
+      source: { kind: 'libraryFile', id: 100, filename: 'Cube.stl' },
+      onClose: vi.fn(),
+    });
+
+    // Wait for auto-pick to populate all four slots from the fullThreeTier
+    // listing — then Save as pipeline becomes enabled.
+    const user = userEvent.setup();
+    let saveBtn: HTMLButtonElement;
+    await waitFor(() => {
+      saveBtn = screen.getByRole('button', { name: /^Save as pipeline$/ }) as HTMLButtonElement;
+      expect(saveBtn.disabled).toBe(false);
+    });
+    await user.click(saveBtn!);
+
+    const nameInput = screen.getByLabelText(/New pipeline name/i);
+    await user.type(nameInput, 'My Default');
+    await user.click(screen.getByRole('button', { name: /^Save$/ }));
+
+    await waitFor(() => {
+      expect(mockApi.createSlicerPipeline).toHaveBeenCalledTimes(1);
+      const body = mockApi.createSlicerPipeline.mock.calls[0][0];
+      expect(body.name).toBe('My Default');
+      // The four slots come from the auto-picked unified-presets listing —
+      // local tier wins per SLICE_MODAL_TIER_ORDER.
+      expect(body.printer_preset.source).toBe('local');
+      expect(body.process_preset.source).toBe('local');
+      expect(body.filament_presets[0].source).toBe('local');
+    });
+  });
+
 });

+ 52 - 0
frontend/src/__tests__/pages/SettingsPage.test.tsx

@@ -1289,4 +1289,56 @@ describe('SettingsPage', () => {
       });
     });
   });
+
+  // --------------------------------------------------------------------
+  // Slicer Pipelines (#1425) — Workflow tab sub-tabs
+  // --------------------------------------------------------------------
+  describe('workflow sub-tabs (#1425)', () => {
+    beforeEach(() => {
+      // Endpoints the Pipelines panel calls (#1425).
+      server.use(
+        http.get('/api/v1/slicer-pipelines/', () => HttpResponse.json({ pipelines: [] })),
+        http.get('/api/v1/slicer/presets', () =>
+          HttpResponse.json({
+            orca_cloud: { printer: [], process: [], filament: [] },
+            cloud: { printer: [], process: [], filament: [] },
+            local: { printer: [], process: [], filament: [] },
+            standard: { printer: [], process: [], filament: [] },
+            cloud_status: 'ok',
+            orca_cloud_status: 'ok',
+          }),
+        ),
+      );
+    });
+
+    it('renders Queue & Dispatch + Pipelines sub-tabs under Workflow', async () => {
+      render(<SettingsPage />);
+      const user = userEvent.setup();
+      await waitFor(() => {
+        // Workflow tab in the sidebar — exact match to avoid colliding with
+        // "Print Queue" or "Queue Settings" labels elsewhere on the page.
+        expect(screen.getByRole('button', { name: 'Workflow' })).toBeInTheDocument();
+      });
+      await user.click(screen.getByRole('button', { name: 'Workflow' }));
+      await waitFor(() => {
+        expect(screen.getByRole('button', { name: /Queue & Dispatch/i })).toBeInTheDocument();
+        expect(screen.getByRole('button', { name: /^Pipelines$/i })).toBeInTheDocument();
+      });
+    });
+
+    it('clicking Pipelines sub-tab shows the empty-state hint and updates the URL', async () => {
+      render(<SettingsPage />);
+      const user = userEvent.setup();
+      await waitFor(() => expect(screen.getByRole('button', { name: 'Workflow' })).toBeInTheDocument());
+      await user.click(screen.getByRole('button', { name: 'Workflow' }));
+      await user.click(screen.getByRole('button', { name: /^Pipelines$/i }));
+
+      await waitFor(() => {
+        expect(screen.getByText(/No pipelines yet/i)).toBeInTheDocument();
+        // Deep-link URL carries both ?tab=queue and ?sub=pipelines
+        expect(window.location.search).toContain('tab=queue');
+        expect(window.location.search).toContain('sub=pipelines');
+      });
+    });
+  });
 });

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

@@ -1502,6 +1502,39 @@ export interface UnifiedPresetsResponse {
   orca_cloud_status: SlicerCloudStatus;
 }
 
+// Slicer Pipelines (#1425) — named bundles of preset slots the SliceModal
+// can apply in one click. PR A surfaces only the bundle; target_* and
+// fanout_strategy round-trip from the backend but the UI doesn't yet expose
+// them (they come alive in PR B / PR C).
+export interface SlicerPipeline {
+  id: number;
+  name: string;
+  description: string | null;
+  printer_preset: PresetRef;
+  process_preset: PresetRef;
+  filament_presets: PresetRef[];
+  bed_type: string | null;
+  target_kind: 'specific_printer' | 'printer_class';
+  target_printer_id: number | null;
+  target_model_class: string | null;
+  fanout_strategy: 'max_parallel' | 'fill_one_first' | 'round_robin';
+  created_by: number | null;
+  created_at: string;
+  updated_at: string;
+}
+export interface SlicerPipelineCreateRequest {
+  name: string;
+  description?: string | null;
+  printer_preset: PresetRef;
+  process_preset: PresetRef;
+  filament_presets: PresetRef[];
+  bed_type?: string | null;
+}
+export type SlicerPipelineUpdateRequest = Partial<SlicerPipelineCreateRequest>;
+export interface SlicerPipelinesListResponse {
+  pipelines: SlicerPipeline[];
+}
+
 export interface SliceResponse {
   library_file_id: number;
   name: string;
@@ -6199,6 +6232,25 @@ export const api = {
       options?.refresh ? '/slicer/presets?refresh=true' : '/slicer/presets',
     ),
 
+  // Slicer Pipelines (#1425) — preset bundles the SliceModal can apply in
+  // one click. CRUD is gated on PIPELINES_READ / PIPELINES_WRITE.
+  listSlicerPipelines: () =>
+    request<SlicerPipelinesListResponse>('/slicer-pipelines/'),
+  getSlicerPipeline: (id: number) =>
+    request<SlicerPipeline>(`/slicer-pipelines/${id}`),
+  createSlicerPipeline: (data: SlicerPipelineCreateRequest) =>
+    request<SlicerPipeline>('/slicer-pipelines/', {
+      method: 'POST',
+      body: JSON.stringify(data),
+    }),
+  updateSlicerPipeline: (id: number, data: SlicerPipelineUpdateRequest) =>
+    request<SlicerPipeline>(`/slicer-pipelines/${id}`, {
+      method: 'PUT',
+      body: JSON.stringify(data),
+    }),
+  deleteSlicerPipeline: (id: number) =>
+    request<void>(`/slicer-pipelines/${id}`, { method: 'DELETE' }),
+
   // Canonical Bambu printer-model registry — "Bambu Lab <model>" → short code.
   // Single source of truth shared with backend (PRINTER_MODEL_MAP); the
   // SliceModal uses this to classify cloud / standard presets by their

+ 145 - 0
frontend/src/components/SliceModal.tsx

@@ -343,6 +343,35 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
   // user had no way to switch plates without cloning the preset.
   const [bedType, setBedType] = useState<string | null>(null);
 
+  // Slicer Pipelines (#1425) — apply a saved preset bundle to all four slots
+  // with one pick, or save the current selection as a new pipeline.
+  const pipelinesQuery = useQuery({
+    queryKey: ['slicer-pipelines'],
+    queryFn: () => api.listSlicerPipelines(),
+    staleTime: 60_000,
+  });
+  const [savePipelineOpen, setSavePipelineOpen] = useState(false);
+  const [pipelineDraftName, setPipelineDraftName] = useState('');
+  const { showToast } = useToast();
+  const createPipelineMutation = useMutation({
+    mutationFn: (body: {
+      name: string;
+      printer_preset: PresetRef;
+      process_preset: PresetRef;
+      filament_presets: PresetRef[];
+      bed_type: string | null;
+    }) => api.createSlicerPipeline(body),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['slicer-pipelines'] });
+      showToast(t('slice.pipelines.toast.saved', 'Pipeline saved'), 'success');
+      setSavePipelineOpen(false);
+      setPipelineDraftName('');
+    },
+    onError: (err: Error) => {
+      showToast(err.message || t('slice.pipelines.toast.saveFailed', 'Save failed'), 'error');
+    },
+  });
+
   const platesQuery = useQuery({
     queryKey: ['slicePlates', source.kind, source.id],
     queryFn: async () => {
@@ -682,6 +711,122 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
                   status === 'ok' (returns null in that case), but the Refresh
                   button stays visible regardless so users can pick up cloud /
                   bundled changes even when sign-in is healthy. */}
+              {/* Slicer Pipelines (#1425): apply a saved preset bundle to all
+                  four slots, or save the current selection as a pipeline.
+                  Pipelines are managed in Settings → Workflow → Pipelines. */}
+              <div className="flex flex-wrap items-center gap-2 px-2 py-1.5 rounded-md bg-bambu-dark/40 border border-bambu-dark-tertiary">
+                <span className="text-xs font-medium text-bambu-gray flex items-center gap-1">
+                  <Cog className="w-3.5 h-3.5" /> {t('slice.pipelines.label', 'Pipeline')}
+                </span>
+                <select
+                  value=""
+                  disabled={isEnqueuing || (pipelinesQuery.data?.pipelines.length ?? 0) === 0}
+                  onChange={(e) => {
+                    const id = parseInt(e.target.value, 10);
+                    if (Number.isNaN(id)) return;
+                    const picked = pipelinesQuery.data?.pipelines.find((p) => p.id === id);
+                    if (!picked) return;
+                    // Apply slot state. The filament list is right-padded from
+                    // current state so a pipeline with fewer entries than the
+                    // current source's slot count keeps the existing tail.
+                    setPrinterPreset(picked.printer_preset);
+                    setProcessPreset(picked.process_preset);
+                    setBedType(picked.bed_type);
+                    setFilamentPresets((current) => {
+                      const next = current.length > 0 ? [...current] : picked.filament_presets.map(() => null);
+                      for (let i = 0; i < next.length; i++) {
+                        if (i < picked.filament_presets.length) {
+                          next[i] = picked.filament_presets[i];
+                        }
+                      }
+                      return next;
+                    });
+                    showToast(t('slice.pipelines.toast.applied', 'Applied "{{name}}"', { name: picked.name }), 'success');
+                    // Reset the dropdown so the user can re-apply the same
+                    // pipeline if needed (selects don't fire onChange when
+                    // value reselects the same option).
+                    e.target.value = '';
+                  }}
+                  className="text-xs px-2 py-1 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white disabled:opacity-50 disabled:cursor-not-allowed flex-1 min-w-[10ch]"
+                  aria-label={t('slice.pipelines.applyAria', 'Apply pipeline')}
+                >
+                  <option value="">
+                    {(pipelinesQuery.data?.pipelines.length ?? 0) === 0
+                      ? t('slice.pipelines.empty', 'No saved pipelines')
+                      : t('slice.pipelines.applyPrompt', 'Apply pipeline…')}
+                  </option>
+                  {pipelinesQuery.data?.pipelines.map((p) => (
+                    <option key={p.id} value={p.id}>
+                      {p.name}
+                    </option>
+                  ))}
+                </select>
+                {!savePipelineOpen ? (
+                  <button
+                    type="button"
+                    onClick={() => {
+                      setPipelineDraftName('');
+                      setSavePipelineOpen(true);
+                    }}
+                    disabled={
+                      isEnqueuing ||
+                      !printerPreset ||
+                      !processPreset ||
+                      filamentPresets.length === 0 ||
+                      filamentPresets.some((f) => f === null)
+                    }
+                    className="text-xs px-2 py-1 bg-bambu-green/20 hover:bg-bambu-green/30 text-bambu-green border border-bambu-green/40 rounded disabled:opacity-50 disabled:cursor-not-allowed"
+                    title={t('slice.pipelines.saveTitle', 'Save the current four-slot selection as a reusable pipeline')}
+                  >
+                    {t('slice.pipelines.saveButton', 'Save as pipeline')}
+                  </button>
+                ) : (
+                  <div className="flex items-center gap-1 flex-1 min-w-[16ch]">
+                    <input
+                      autoFocus
+                      value={pipelineDraftName}
+                      onChange={(e) => setPipelineDraftName(e.target.value)}
+                      placeholder={t('slice.pipelines.namePlaceholder', 'Pipeline name')}
+                      aria-label={t('slice.pipelines.nameAria', 'New pipeline name')}
+                      className="flex-1 text-xs px-2 py-1 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white"
+                    />
+                    <button
+                      type="button"
+                      onClick={() => {
+                        const trimmed = pipelineDraftName.trim();
+                        if (!trimmed || !printerPreset || !processPreset) return;
+                        const nonNull = filamentPresets.filter((f): f is PresetRef => f !== null);
+                        if (nonNull.length === 0) return;
+                        createPipelineMutation.mutate({
+                          name: trimmed,
+                          printer_preset: printerPreset,
+                          process_preset: processPreset,
+                          filament_presets: nonNull,
+                          bed_type: bedType,
+                        });
+                      }}
+                      disabled={createPipelineMutation.isPending || !pipelineDraftName.trim()}
+                      className="text-xs px-2 py-1 bg-bambu-green hover:bg-bambu-green/80 text-white rounded disabled:opacity-50"
+                    >
+                      {createPipelineMutation.isPending ? (
+                        <Loader2 className="w-3 h-3 animate-spin" />
+                      ) : (
+                        t('common.save', 'Save')
+                      )}
+                    </button>
+                    <button
+                      type="button"
+                      onClick={() => {
+                        setSavePipelineOpen(false);
+                        setPipelineDraftName('');
+                      }}
+                      className="text-xs px-2 py-1 text-bambu-gray hover:text-white"
+                    >
+                      {t('common.cancel', 'Cancel')}
+                    </button>
+                  </div>
+                )}
+              </div>
               <PresetDropdown
                 label={t('slice.printer')}
                 slot="printer"

+ 315 - 0
frontend/src/components/SlicerPipelinesPanel.tsx

@@ -0,0 +1,315 @@
+import { useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import { AlertTriangle, Check, Edit2, Loader2, Trash2, Workflow, X } from 'lucide-react';
+import {
+  api,
+  type PresetRef,
+  type PresetSource,
+  type SlicerPipeline,
+  type UnifiedPresetsResponse,
+} from '../api/client';
+import { Card, CardContent, CardHeader } from './Card';
+import { useToast } from '../contexts/ToastContext';
+
+// Resolve a PresetRef back to its pretty name via the unified-presets listing.
+// Returns null when the ref no longer points at a known preset — render a
+// "deleted" badge in that case so users can see what to fix.
+function resolveName(presets: UnifiedPresetsResponse | undefined, slot: 'printer' | 'process' | 'filament', ref: PresetRef): string | null {
+  if (!presets) return null;
+  const list = presets[ref.source]?.[slot] ?? [];
+  const hit = list.find((p) => p.id === ref.id);
+  return hit ? hit.name : null;
+}
+
+const SOURCE_LABEL: Record<PresetSource, string> = {
+  orca_cloud: 'Orca Cloud',
+  cloud: 'Bambu Cloud',
+  local: 'Imported',
+  standard: 'Standard',
+};
+
+export function SlicerPipelinesPanel() {
+  const { t } = useTranslation();
+  const queryClient = useQueryClient();
+  const { showToast } = useToast();
+
+  const { data: list, isLoading, error } = useQuery({
+    queryKey: ['slicer-pipelines'],
+    queryFn: () => api.listSlicerPipelines(),
+  });
+
+  // The unified presets endpoint is the source of pretty names for each
+  // PresetRef. Same listing the SliceModal pulls — reused here to avoid a
+  // second round-trip to the slicer registry.
+  const { data: presets } = useQuery({
+    queryKey: ['slicer-presets'],
+    queryFn: () => api.getSlicerPresets(),
+  });
+
+  const updateMutation = useMutation({
+    mutationFn: ({ id, name, description }: { id: number; name?: string; description?: string | null }) =>
+      api.updateSlicerPipeline(id, { name, description }),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['slicer-pipelines'] });
+      showToast(t('settings.pipelines.toast.saved', 'Pipeline saved'), 'success');
+    },
+    onError: (err: Error) => {
+      showToast(err.message || t('settings.pipelines.toast.saveFailed', 'Save failed'), 'error');
+    },
+  });
+
+  const deleteMutation = useMutation({
+    mutationFn: (id: number) => api.deleteSlicerPipeline(id),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['slicer-pipelines'] });
+      showToast(t('settings.pipelines.toast.deleted', 'Pipeline deleted'), 'success');
+    },
+    onError: (err: Error) => {
+      showToast(err.message || t('settings.pipelines.toast.deleteFailed', 'Delete failed'), 'error');
+    },
+  });
+
+  const pipelines = list?.pipelines ?? [];
+
+  return (
+    <Card>
+      <CardHeader>
+        <h3 className="text-base font-semibold text-white flex items-center gap-2">
+          <Workflow className="w-4 h-4 text-bambu-green" />
+          {t('settings.pipelines.title', 'Slicer Pipelines')}
+        </h3>
+        <p className="text-xs text-bambu-gray mt-1">
+          {t(
+            'settings.pipelines.subtitle',
+            'Reusable preset bundles (printer + process + filaments + bed type). Save one from the Slice dialog and apply it with a single click on the next file.',
+          )}
+        </p>
+      </CardHeader>
+      <CardContent>
+        {isLoading && (
+          <div className="flex items-center gap-2 text-sm text-bambu-gray">
+            <Loader2 className="w-4 h-4 animate-spin" />
+            {t('settings.pipelines.loading', 'Loading pipelines…')}
+          </div>
+        )}
+        {error && (
+          <div className="text-sm text-red-400">
+            {t('settings.pipelines.loadError', 'Could not load pipelines.')}
+          </div>
+        )}
+        {!isLoading && !error && pipelines.length === 0 && (
+          <div className="text-sm text-bambu-gray space-y-2">
+            <p>{t('settings.pipelines.empty.title', 'No pipelines yet.')}</p>
+            <p>
+              {t(
+                'settings.pipelines.empty.howto',
+                'Open the Slice dialog for any file, pick your printer / process / filaments / bed type, then click "Save as pipeline". Your saved pipelines will appear here.',
+              )}
+            </p>
+          </div>
+        )}
+        {!isLoading && !error && pipelines.length > 0 && (
+          <div className="space-y-2">
+            {pipelines.map((p) => (
+              <PipelineRow
+                key={p.id}
+                pipeline={p}
+                presets={presets}
+                onRename={(name, description) => updateMutation.mutate({ id: p.id, name, description })}
+                onDelete={() => {
+                  if (confirm(t('settings.pipelines.confirmDelete', 'Delete this pipeline? This cannot be undone.'))) {
+                    deleteMutation.mutate(p.id);
+                  }
+                }}
+                saving={updateMutation.isPending}
+                deleting={deleteMutation.isPending}
+              />
+            ))}
+          </div>
+        )}
+      </CardContent>
+    </Card>
+  );
+}
+
+function PipelineRow({
+  pipeline,
+  presets,
+  onRename,
+  onDelete,
+  saving,
+  deleting,
+}: {
+  pipeline: SlicerPipeline;
+  presets: UnifiedPresetsResponse | undefined;
+  onRename: (name: string, description: string | null) => void;
+  onDelete: () => void;
+  saving: boolean;
+  deleting: boolean;
+}) {
+  const { t } = useTranslation();
+  const [editing, setEditing] = useState(false);
+  const [draftName, setDraftName] = useState(pipeline.name);
+  const [draftDescription, setDraftDescription] = useState(pipeline.description ?? '');
+
+  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));
+  const hasStaleRef =
+    presets !== undefined &&
+    (printerName === null || processName === null || filamentResolutions.some((n) => n === null));
+
+  const handleSave = () => {
+    const trimmedName = draftName.trim();
+    if (!trimmedName) return;
+    onRename(trimmedName, draftDescription.trim() || null);
+    setEditing(false);
+  };
+
+  const handleCancel = () => {
+    setDraftName(pipeline.name);
+    setDraftDescription(pipeline.description ?? '');
+    setEditing(false);
+  };
+
+  return (
+    <div className="rounded-md border border-bambu-dark-tertiary bg-bambu-dark/40 px-3 py-2">
+      <div className="flex items-start justify-between gap-3">
+        <div className="min-w-0 flex-1">
+          {editing ? (
+            <div className="space-y-2">
+              <input
+                value={draftName}
+                onChange={(e) => setDraftName(e.target.value)}
+                aria-label={t('settings.pipelines.field.name', 'Pipeline name')}
+                placeholder={t('settings.pipelines.field.name', 'Pipeline name')}
+                className="w-full px-2 py-1 text-sm bg-bambu-dark border border-bambu-dark-tertiary rounded text-white"
+              />
+              <textarea
+                value={draftDescription}
+                onChange={(e) => setDraftDescription(e.target.value)}
+                aria-label={t('settings.pipelines.field.description', 'Description')}
+                placeholder={t('settings.pipelines.field.description', 'Description')}
+                rows={2}
+                className="w-full px-2 py-1 text-xs bg-bambu-dark border border-bambu-dark-tertiary rounded text-white"
+              />
+            </div>
+          ) : (
+            <>
+              <h4 className="text-sm font-medium text-white truncate">{pipeline.name}</h4>
+              {pipeline.description && (
+                <p className="text-xs text-bambu-gray mt-0.5">{pipeline.description}</p>
+              )}
+            </>
+          )}
+        </div>
+        <div className="flex items-center gap-1 flex-shrink-0">
+          {editing ? (
+            <>
+              <button
+                onClick={handleSave}
+                disabled={saving || !draftName.trim()}
+                aria-label={t('settings.pipelines.action.save', 'Save')}
+                className="p-1.5 text-bambu-green hover:bg-bambu-dark-tertiary rounded disabled:opacity-50"
+              >
+                <Check className="w-4 h-4" />
+              </button>
+              <button
+                onClick={handleCancel}
+                aria-label={t('settings.pipelines.action.cancel', 'Cancel')}
+                className="p-1.5 text-bambu-gray hover:bg-bambu-dark-tertiary rounded"
+              >
+                <X className="w-4 h-4" />
+              </button>
+            </>
+          ) : (
+            <>
+              <button
+                onClick={() => setEditing(true)}
+                aria-label={t('settings.pipelines.action.rename', 'Rename')}
+                className="p-1.5 text-bambu-gray hover:text-white hover:bg-bambu-dark-tertiary rounded"
+              >
+                <Edit2 className="w-4 h-4" />
+              </button>
+              <button
+                onClick={onDelete}
+                disabled={deleting}
+                aria-label={t('settings.pipelines.action.delete', 'Delete')}
+                className="p-1.5 text-bambu-gray hover:text-red-400 hover:bg-bambu-dark-tertiary rounded disabled:opacity-50"
+              >
+                <Trash2 className="w-4 h-4" />
+              </button>
+            </>
+          )}
+        </div>
+      </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) => (
+            <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]}
+            />
+          ))}
+          {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>
+      )}
+
+      {hasStaleRef && !editing && (
+        <div className="mt-2 flex items-center gap-1.5 text-xs text-amber-400">
+          <AlertTriangle className="w-3.5 h-3.5" />
+          {t(
+            'settings.pipelines.staleWarning',
+            'One or more referenced presets no longer exist. Re-save this pipeline from the Slice dialog to fix.',
+          )}
+        </div>
+      )}
+    </div>
+  );
+}
+
+function PresetLine({
+  label,
+  ref,
+  name,
+}: {
+  label: string;
+  ref: PresetRef;
+  name: string | null;
+}) {
+  return (
+    <div className="text-bambu-gray truncate">
+      <span className="font-medium text-bambu-gray/80">{label}:</span>{' '}
+      {name ? (
+        <span className="text-white">{name}</span>
+      ) : (
+        <span className="text-amber-400">[{SOURCE_LABEL[ref.source]} #{ref.id}]</span>
+      )}
+    </div>
+  );
+}

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

@@ -1558,6 +1558,8 @@ export default {
       smartPlugs: 'Smart Plugs',
       notifications: 'Benachrichtigungen',
       queue: 'Workflow',
+      queueDispatch: 'Warteschlange & Dispatch',
+      queuePipelines: 'Pipelines',
       filament: 'Filament',
       network: 'Netzwerk',
       apiKeys: 'API-Schlüssel',
@@ -2551,6 +2553,44 @@ export default {
       migrationErrorWarning: '{{count}} Legacy-Eintrag/Einträge konnten beim Start nicht verschlüsselt werden. Prüfen Sie die Server-Logs und starten Sie Bambuddy neu.',
     },
 
+
+    // Slicer Pipelines (#1425): list/edit/delete preset bundles users saved
+    // from the Slice dialog. Lives in Settings → Workflow → Pipelines sub-tab.
+    pipelines: {
+      title: 'Slicer-Pipelines',
+      subtitle: 'Wiederverwendbare Preset-Bundles (Drucker + Prozess + Filamente + Druckplatte). Speichere eines aus dem Slice-Dialog und wende es beim nächsten Datei-Slice mit einem Klick an.',
+      loading: 'Pipelines werden geladen…',
+      loadError: 'Pipelines konnten nicht geladen werden.',
+      confirmDelete: 'Diese Pipeline löschen? Das kann nicht rückgängig gemacht werden.',
+      staleWarning: 'Eines oder mehrere referenzierte Presets existieren nicht mehr. Speichere diese Pipeline erneut aus dem Slice-Dialog, um sie zu reparieren.',
+      empty: {
+        title: 'Noch keine Pipelines.',
+        howto: 'Öffne den Slice-Dialog für eine beliebige Datei, wähle Drucker / Prozess / Filamente / Druckplatte und klicke „Als Pipeline speichern“. Deine gespeicherten Pipelines erscheinen hier.',
+      },
+      field: {
+        name: 'Pipeline-Name',
+        description: 'Beschreibung',
+      },
+      action: {
+        save: 'Speichern',
+        cancel: 'Abbrechen',
+        rename: 'Umbenennen',
+        delete: 'Löschen',
+      },
+      slot: {
+        printer: 'Drucker',
+        process: 'Prozess',
+        filament: 'Filament',
+        filamentN: 'Filament {{n}}',
+        bed: 'Druckplatte',
+      },
+      toast: {
+        saved: 'Pipeline gespeichert',
+        saveFailed: 'Speichern fehlgeschlagen',
+        deleted: 'Pipeline gelöscht',
+        deleteFailed: 'Löschen fehlgeschlagen',
+      },
+    },
   },
 
   // Notifications (for push notifications)
@@ -3844,6 +3884,22 @@ export default {
       texturedPEI: 'Textured PEI Plate',
       smoothPEI: 'Smooth PEI Plate',
     },
+    // Slicer Pipelines (#1425) — apply a saved bundle or save the current pick.
+    pipelines: {
+      label: 'Pipeline',
+      applyAria: 'Pipeline anwenden',
+      applyPrompt: 'Pipeline anwenden…',
+      empty: 'Keine gespeicherten Pipelines',
+      saveButton: 'Als Pipeline speichern',
+      saveTitle: 'Aktuelle Auswahl aller vier Slots als wiederverwendbare Pipeline speichern',
+      namePlaceholder: 'Pipeline-Name',
+      nameAria: 'Neuer Pipeline-Name',
+      toast: {
+        applied: '„{{name}}“ angewendet',
+        saved: 'Pipeline gespeichert',
+        saveFailed: 'Speichern fehlgeschlagen',
+      },
+    },
   },
 
   // Spoolman

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

@@ -1572,6 +1572,8 @@ export default {
       smartPlugs: 'Smart Plugs',
       notifications: 'Notifications',
       queue: 'Workflow',
+      queueDispatch: 'Queue & Dispatch',
+      queuePipelines: 'Pipelines',
       filament: 'Filament',
       network: 'Network',
       apiKeys: 'API Keys',
@@ -2568,6 +2570,43 @@ export default {
       migrationErrorWarning: '{{count}} legacy row(s) failed to re-encrypt at startup. Check server logs and restart Bambuddy to retry.',
     },
 
+    // Slicer Pipelines (#1425): list/edit/delete preset bundles users saved
+    // from the Slice dialog. Lives in Settings → Workflow → Pipelines sub-tab.
+    pipelines: {
+      title: 'Slicer Pipelines',
+      subtitle: 'Reusable preset bundles (printer + process + filaments + bed type). Save one from the Slice dialog and apply it with a single click on the next file.',
+      loading: 'Loading pipelines…',
+      loadError: 'Could not load pipelines.',
+      confirmDelete: 'Delete this pipeline? This cannot be undone.',
+      staleWarning: 'One or more referenced presets no longer exist. Re-save this pipeline from the Slice dialog to fix.',
+      empty: {
+        title: 'No pipelines yet.',
+        howto: 'Open the Slice dialog for any file, pick your printer / process / filaments / bed type, then click "Save as pipeline". Your saved pipelines will appear here.',
+      },
+      field: {
+        name: 'Pipeline name',
+        description: 'Description',
+      },
+      action: {
+        save: 'Save',
+        cancel: 'Cancel',
+        rename: 'Rename',
+        delete: 'Delete',
+      },
+      slot: {
+        printer: 'Printer',
+        process: 'Process',
+        filament: 'Filament',
+        filamentN: 'Filament {{n}}',
+        bed: 'Bed',
+      },
+      toast: {
+        saved: 'Pipeline saved',
+        saveFailed: 'Save failed',
+        deleted: 'Pipeline deleted',
+        deleteFailed: 'Delete failed',
+      },
+    },
   },
 
   // Notifications (for push notifications)
@@ -3861,6 +3900,22 @@ export default {
       texturedPEI: 'Textured PEI Plate',
       smoothPEI: 'Smooth PEI Plate',
     },
+    // Slicer Pipelines (#1425) — apply a saved bundle or save the current pick.
+    pipelines: {
+      label: 'Pipeline',
+      applyAria: 'Apply pipeline',
+      applyPrompt: 'Apply pipeline…',
+      empty: 'No saved pipelines',
+      saveButton: 'Save as pipeline',
+      saveTitle: 'Save the current four-slot selection as a reusable pipeline',
+      namePlaceholder: 'Pipeline name',
+      nameAria: 'New pipeline name',
+      toast: {
+        applied: 'Applied "{{name}}"',
+        saved: 'Pipeline saved',
+        saveFailed: 'Save failed',
+      },
+    },
   },
 
   // Spoolman

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

@@ -1559,6 +1559,8 @@ export default {
       smartPlugs: 'Enchufes inteligentes',
       notifications: 'Notificaciones',
       queue: 'Flujo de trabajo',
+      queueDispatch: 'Cola y Despacho',
+      queuePipelines: 'Pipelines',
       filament: 'Filamento',
       network: 'Red',
       apiKeys: 'Claves API',
@@ -2554,6 +2556,44 @@ export default {
       migrationErrorWarning: '{{count}} fila(s) heredada(s) no se pudieron volver a cifrar al iniciar. Revise los registros del servidor y reinicie Bambuddy para reintentarlo.',
     },
 
+
+    // Slicer Pipelines (#1425): list/edit/delete preset bundles users saved
+    // from the Slice dialog. Lives in Settings → Workflow → Pipelines sub-tab.
+    pipelines: {
+      title: 'Pipelines del Cortador',
+      subtitle: 'Paquetes reutilizables de preajustes (impresora + proceso + filamentos + tipo de placa). Guarda uno desde el diálogo Cortar y aplícalo con un clic al siguiente archivo.',
+      loading: 'Cargando pipelines…',
+      loadError: 'No se pudieron cargar las pipelines.',
+      confirmDelete: '¿Eliminar esta pipeline? Esto no se puede deshacer.',
+      staleWarning: 'Uno o más preajustes referenciados ya no existen. Vuelve a guardar esta pipeline desde el diálogo Cortar para corregirla.',
+      empty: {
+        title: 'Aún no hay pipelines.',
+        howto: 'Abre el diálogo Cortar para cualquier archivo, elige impresora / proceso / filamentos / placa, y haz clic en "Guardar como pipeline". Tus pipelines guardadas aparecerán aquí.',
+      },
+      field: {
+        name: 'Nombre de la pipeline',
+        description: 'Descripción',
+      },
+      action: {
+        save: 'Guardar',
+        cancel: 'Cancelar',
+        rename: 'Renombrar',
+        delete: 'Eliminar',
+      },
+      slot: {
+        printer: 'Impresora',
+        process: 'Proceso',
+        filament: 'Filamento',
+        filamentN: 'Filamento {{n}}',
+        bed: 'Placa',
+      },
+      toast: {
+        saved: 'Pipeline guardada',
+        saveFailed: 'Error al guardar',
+        deleted: 'Pipeline eliminada',
+        deleteFailed: 'Error al eliminar',
+      },
+    },
   },
 
   // Notifications (for push notifications)
@@ -3847,6 +3887,22 @@ export default {
       texturedPEI: 'Textured PEI Plate',
       smoothPEI: 'Smooth PEI Plate',
     },
+    // Slicer Pipelines (#1425) — apply a saved bundle or save the current pick.
+    pipelines: {
+      label: 'Pipeline',
+      applyAria: 'Aplicar pipeline',
+      applyPrompt: 'Aplicar pipeline…',
+      empty: 'Sin pipelines guardadas',
+      saveButton: 'Guardar como pipeline',
+      saveTitle: 'Guardar la selección actual de los cuatro ajustes como pipeline reutilizable',
+      namePlaceholder: 'Nombre de la pipeline',
+      nameAria: 'Nuevo nombre de pipeline',
+      toast: {
+        applied: '"{{name}}" aplicada',
+        saved: 'Pipeline guardada',
+        saveFailed: 'Error al guardar',
+      },
+    },
   },
 
   // Spoolman

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

@@ -1558,6 +1558,8 @@ export default {
       smartPlugs: 'Prises connectées',
       notifications: 'Notifications',
       queue: 'Flux de travail',
+      queueDispatch: 'File & Distribution',
+      queuePipelines: 'Pipelines',
       filament: 'Filament',
       network: 'Réseau',
       apiKeys: 'Clés API',
@@ -2540,6 +2542,44 @@ export default {
       commandQueued: 'Commande en file d\'attente',
       commandError: 'Échec de l\'envoi de la commande',
     },
+
+    // Slicer Pipelines (#1425): list/edit/delete preset bundles users saved
+    // from the Slice dialog. Lives in Settings → Workflow → Pipelines sub-tab.
+    pipelines: {
+      title: 'Pipelines du Trancheur',
+      subtitle: 'Lots de préréglages réutilisables (imprimante + processus + filaments + type de plateau). Enregistrez-en un depuis le dialogue Trancher et appliquez-le en un clic au fichier suivant.',
+      loading: 'Chargement des pipelines…',
+      loadError: 'Impossible de charger les pipelines.',
+      confirmDelete: 'Supprimer ce pipeline ? Cela ne peut pas être annulé.',
+      staleWarning: 'Un ou plusieurs préréglages référencés n\'existent plus. Réenregistrez ce pipeline depuis le dialogue Trancher pour le corriger.',
+      empty: {
+        title: 'Aucun pipeline pour le moment.',
+        howto: 'Ouvrez le dialogue Trancher pour n\'importe quel fichier, choisissez imprimante / processus / filaments / plateau, puis cliquez sur « Enregistrer comme pipeline ». Vos pipelines enregistrés apparaîtront ici.',
+      },
+      field: {
+        name: 'Nom du pipeline',
+        description: 'Description',
+      },
+      action: {
+        save: 'Enregistrer',
+        cancel: 'Annuler',
+        rename: 'Renommer',
+        delete: 'Supprimer',
+      },
+      slot: {
+        printer: 'Imprimante',
+        process: 'Processus',
+        filament: 'Filament',
+        filamentN: 'Filament {{n}}',
+        bed: 'Plateau',
+      },
+      toast: {
+        saved: 'Pipeline enregistré',
+        saveFailed: 'Échec de l\'enregistrement',
+        deleted: 'Pipeline supprimé',
+        deleteFailed: 'Échec de la suppression',
+      },
+    },
   },
 
   // Notifications (for push notifications)
@@ -3833,6 +3873,22 @@ export default {
       texturedPEI: 'Textured PEI Plate',
       smoothPEI: 'Smooth PEI Plate',
     },
+    // Slicer Pipelines (#1425) — apply a saved bundle or save the current pick.
+    pipelines: {
+      label: 'Pipeline',
+      applyAria: 'Appliquer le pipeline',
+      applyPrompt: 'Appliquer un pipeline…',
+      empty: 'Aucun pipeline enregistré',
+      saveButton: 'Enregistrer comme pipeline',
+      saveTitle: 'Enregistrer la sélection actuelle des quatre emplacements comme pipeline réutilisable',
+      namePlaceholder: 'Nom du pipeline',
+      nameAria: 'Nouveau nom de pipeline',
+      toast: {
+        applied: '« {{name}} » appliqué',
+        saved: 'Pipeline enregistré',
+        saveFailed: 'Échec de l\'enregistrement',
+      },
+    },
   },
 
   // Spoolman

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

@@ -1558,6 +1558,8 @@ export default {
       smartPlugs: 'Prese smart',
       notifications: 'Notifiche',
       queue: 'Flusso',
+      queueDispatch: 'Coda e Dispatch',
+      queuePipelines: 'Pipeline',
       filament: 'Filamento',
       network: 'Rete',
       apiKeys: 'Chiavi API',
@@ -2539,6 +2541,44 @@ export default {
       commandQueued: 'Comando in coda',
       commandError: 'Invio comando non riuscito',
     },
+
+    // Slicer Pipelines (#1425): list/edit/delete preset bundles users saved
+    // from the Slice dialog. Lives in Settings → Workflow → Pipelines sub-tab.
+    pipelines: {
+      title: 'Pipeline dello Slicer',
+      subtitle: 'Bundle di preset riutilizzabili (stampante + processo + filamenti + tipo di piatto). Salvane uno dal dialogo Slice e applicalo con un clic al file successivo.',
+      loading: 'Caricamento pipeline…',
+      loadError: 'Impossibile caricare le pipeline.',
+      confirmDelete: 'Eliminare questa pipeline? L\'operazione non può essere annullata.',
+      staleWarning: 'Uno o più preset referenziati non esistono più. Risalva questa pipeline dal dialogo Slice per ripararla.',
+      empty: {
+        title: 'Nessuna pipeline ancora.',
+        howto: 'Apri il dialogo Slice per un file, scegli stampante / processo / filamenti / piatto, poi clicca "Salva come pipeline". Le tue pipeline salvate appariranno qui.',
+      },
+      field: {
+        name: 'Nome pipeline',
+        description: 'Descrizione',
+      },
+      action: {
+        save: 'Salva',
+        cancel: 'Annulla',
+        rename: 'Rinomina',
+        delete: 'Elimina',
+      },
+      slot: {
+        printer: 'Stampante',
+        process: 'Processo',
+        filament: 'Filamento',
+        filamentN: 'Filamento {{n}}',
+        bed: 'Piatto',
+      },
+      toast: {
+        saved: 'Pipeline salvata',
+        saveFailed: 'Salvataggio non riuscito',
+        deleted: 'Pipeline eliminata',
+        deleteFailed: 'Eliminazione non riuscita',
+      },
+    },
   },
 
   // Notifications (for push notifications)
@@ -3832,6 +3872,22 @@ export default {
       texturedPEI: 'Textured PEI Plate',
       smoothPEI: 'Smooth PEI Plate',
     },
+    // Slicer Pipelines (#1425) — apply a saved bundle or save the current pick.
+    pipelines: {
+      label: 'Pipeline',
+      applyAria: 'Applica pipeline',
+      applyPrompt: 'Applica pipeline…',
+      empty: 'Nessuna pipeline salvata',
+      saveButton: 'Salva come pipeline',
+      saveTitle: 'Salva la selezione corrente di tutti e quattro gli slot come pipeline riutilizzabile',
+      namePlaceholder: 'Nome pipeline',
+      nameAria: 'Nome nuova pipeline',
+      toast: {
+        applied: '"{{name}}" applicata',
+        saved: 'Pipeline salvata',
+        saveFailed: 'Salvataggio non riuscito',
+      },
+    },
   },
 
   // Spoolman

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

@@ -1557,6 +1557,8 @@ export default {
       smartPlugs: 'スマートプラグ',
       notifications: '通知',
       queue: 'ワークフロー',
+      queueDispatch: 'キューとディスパッチ',
+      queuePipelines: 'パイプライン',
       filament: 'フィラメント',
       network: 'ネットワーク',
       apiKeys: 'APIキー',
@@ -2551,6 +2553,44 @@ export default {
       migrationErrorWarning: '{{count}} 件のレガシー行を起動時に再暗号化できませんでした。サーバーログを確認し、Bambuddy を再起動して再試行してください。',
     },
 
+
+    // Slicer Pipelines (#1425): list/edit/delete preset bundles users saved
+    // from the Slice dialog. Lives in Settings → Workflow → Pipelines sub-tab.
+    pipelines: {
+      title: 'スライサーパイプライン',
+      subtitle: '再利用可能なプリセットバンドル(プリンター + プロセス + フィラメント + ベッドタイプ)。スライスダイアログから保存し、次のファイルにワンクリックで適用できます。',
+      loading: 'パイプラインを読み込み中…',
+      loadError: 'パイプラインを読み込めませんでした。',
+      confirmDelete: 'このパイプラインを削除しますか?元に戻せません。',
+      staleWarning: '参照されているプリセットが見つかりません。スライスダイアログから再保存して修正してください。',
+      empty: {
+        title: 'パイプラインはまだありません。',
+        howto: '任意のファイルでスライスダイアログを開き、プリンター / プロセス / フィラメント / ベッドタイプを選んで「パイプラインとして保存」をクリックしてください。保存したパイプラインはここに表示されます。',
+      },
+      field: {
+        name: 'パイプライン名',
+        description: '説明',
+      },
+      action: {
+        save: '保存',
+        cancel: 'キャンセル',
+        rename: '名前変更',
+        delete: '削除',
+      },
+      slot: {
+        printer: 'プリンター',
+        process: 'プロセス',
+        filament: 'フィラメント',
+        filamentN: 'フィラメント {{n}}',
+        bed: 'ベッド',
+      },
+      toast: {
+        saved: 'パイプラインを保存しました',
+        saveFailed: '保存に失敗しました',
+        deleted: 'パイプラインを削除しました',
+        deleteFailed: '削除に失敗しました',
+      },
+    },
   },
 
   // Notifications (for push notifications)
@@ -3844,6 +3884,22 @@ export default {
       texturedPEI: 'Textured PEI Plate',
       smoothPEI: 'Smooth PEI Plate',
     },
+    // Slicer Pipelines (#1425) — apply a saved bundle or save the current pick.
+    pipelines: {
+      label: 'パイプライン',
+      applyAria: 'パイプラインを適用',
+      applyPrompt: 'パイプラインを適用…',
+      empty: '保存されたパイプラインがありません',
+      saveButton: 'パイプラインとして保存',
+      saveTitle: '現在の4つのスロットの選択を再利用可能なパイプラインとして保存',
+      namePlaceholder: 'パイプライン名',
+      nameAria: '新しいパイプライン名',
+      toast: {
+        applied: '「{{name}}」を適用しました',
+        saved: 'パイプラインを保存しました',
+        saveFailed: '保存に失敗しました',
+      },
+    },
   },
 
   // Spoolman

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

@@ -1472,6 +1472,8 @@ export default {
       smartPlugs: '스마트 플러그',
       notifications: '알림',
       queue: '워크플로우',
+      queueDispatch: '큐 및 디스패치',
+      queuePipelines: '파이프라인',
       filament: '필라멘트',
       network: '네트워크',
       apiKeys: 'API 키',
@@ -2406,7 +2408,45 @@ export default {
     updateEnergyCost: '전기 요금 업데이트',
     updateEnergyCostDescription: '이 키가 /settings/electricity-price에 새 kWh당 전기 요금을 POST할 수 있도록 허용합니다. Home Assistant 동적 요금 자동화(Tibber, Octopus 등)에 유용합니다. API 키로 쓸 수 있는 유일한 설정 필드입니다.',
     energyCostBadge: '에너지',
-    passwordRequirements: '최소 8자, 대문자, 소문자, 숫자, 특수문자 각 1개 이상 포함'
+    passwordRequirements: '최소 8자, 대문자, 소문자, 숫자, 특수문자 각 1개 이상 포함',
+
+    // Slicer Pipelines (#1425): list/edit/delete preset bundles users saved
+    // from the Slice dialog. Lives in Settings → Workflow → Pipelines sub-tab.
+    pipelines: {
+      title: '슬라이서 파이프라인',
+      subtitle: '재사용 가능한 프리셋 묶음(프린터 + 프로세스 + 필라멘트 + 베드 유형). 슬라이스 대화상자에서 저장한 후 다음 파일에 한 번의 클릭으로 적용하세요.',
+      loading: '파이프라인 불러오는 중…',
+      loadError: '파이프라인을 불러오지 못했습니다.',
+      confirmDelete: '이 파이프라인을 삭제하시겠습니까? 되돌릴 수 없습니다.',
+      staleWarning: '참조된 프리셋이 더 이상 존재하지 않습니다. 슬라이스 대화상자에서 이 파이프라인을 다시 저장하여 수정하세요.',
+      empty: {
+        title: '아직 파이프라인이 없습니다.',
+        howto: '임의의 파일에서 슬라이스 대화상자를 열고 프린터 / 프로세스 / 필라멘트 / 베드를 선택한 다음 "파이프라인으로 저장"을 클릭하세요. 저장된 파이프라인이 여기에 표시됩니다.',
+      },
+      field: {
+        name: '파이프라인 이름',
+        description: '설명',
+      },
+      action: {
+        save: '저장',
+        cancel: '취소',
+        rename: '이름 변경',
+        delete: '삭제',
+      },
+      slot: {
+        printer: '프린터',
+        process: '프로세스',
+        filament: '필라멘트',
+        filamentN: '필라멘트 {{n}}',
+        bed: '베드',
+      },
+      toast: {
+        saved: '파이프라인이 저장되었습니다',
+        saveFailed: '저장 실패',
+        deleted: '파이프라인이 삭제되었습니다',
+        deleteFailed: '삭제 실패',
+      },
+    },
   },
   notification: {
     printStarted: {
@@ -3636,7 +3676,23 @@ export default {
       highTemp: '고온 플레이트',
       texturedPEI: '텍스처 PEI 플레이트',
       smoothPEI: '매끄러운 PEI 플레이트'
-    }
+    },
+    // Slicer Pipelines (#1425) — apply a saved bundle or save the current pick.
+    pipelines: {
+      label: '파이프라인',
+      applyAria: '파이프라인 적용',
+      applyPrompt: '파이프라인 적용…',
+      empty: '저장된 파이프라인이 없습니다',
+      saveButton: '파이프라인으로 저장',
+      saveTitle: '현재 네 개 슬롯의 선택을 재사용 가능한 파이프라인으로 저장합니다',
+      namePlaceholder: '파이프라인 이름',
+      nameAria: '새 파이프라인 이름',
+      toast: {
+        applied: '"{{name}}" 적용됨',
+        saved: '파이프라인이 저장되었습니다',
+        saveFailed: '저장 실패',
+      },
+    },
   },
   spoolman: {
     title: 'Spoolman 통합',

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

@@ -1558,6 +1558,8 @@ export default {
       smartPlugs: 'Tomadas Inteligentes',
       notifications: 'Notificações',
       queue: 'Fluxo',
+      queueDispatch: 'Fila e Dispatch',
+      queuePipelines: 'Pipelines',
       filament: 'Filamento',
       network: 'Rede',
       apiKeys: 'Chaves API',
@@ -2539,6 +2541,44 @@ export default {
       commandQueued: 'Comando enfileirado',
       commandError: 'Falha ao enviar comando',
     },
+
+    // Slicer Pipelines (#1425): list/edit/delete preset bundles users saved
+    // from the Slice dialog. Lives in Settings → Workflow → Pipelines sub-tab.
+    pipelines: {
+      title: 'Pipelines do Slicer',
+      subtitle: 'Pacotes de predefinições reutilizáveis (impressora + processo + filamentos + tipo de mesa). Salve um a partir do diálogo Cortar e aplique com um clique no próximo arquivo.',
+      loading: 'Carregando pipelines…',
+      loadError: 'Não foi possível carregar as pipelines.',
+      confirmDelete: 'Excluir esta pipeline? Isso não pode ser desfeito.',
+      staleWarning: 'Uma ou mais predefinições referenciadas não existem mais. Salve novamente esta pipeline a partir do diálogo Cortar para corrigir.',
+      empty: {
+        title: 'Ainda não há pipelines.',
+        howto: 'Abra o diálogo Cortar em qualquer arquivo, escolha impressora / processo / filamentos / mesa e clique em "Salvar como pipeline". Suas pipelines salvas aparecerão aqui.',
+      },
+      field: {
+        name: 'Nome da pipeline',
+        description: 'Descrição',
+      },
+      action: {
+        save: 'Salvar',
+        cancel: 'Cancelar',
+        rename: 'Renomear',
+        delete: 'Excluir',
+      },
+      slot: {
+        printer: 'Impressora',
+        process: 'Processo',
+        filament: 'Filamento',
+        filamentN: 'Filamento {{n}}',
+        bed: 'Mesa',
+      },
+      toast: {
+        saved: 'Pipeline salva',
+        saveFailed: 'Falha ao salvar',
+        deleted: 'Pipeline excluída',
+        deleteFailed: 'Falha ao excluir',
+      },
+    },
   },
 
   // Notifications (for push notifications)
@@ -3832,6 +3872,22 @@ export default {
       texturedPEI: 'Textured PEI Plate',
       smoothPEI: 'Smooth PEI Plate',
     },
+    // Slicer Pipelines (#1425) — apply a saved bundle or save the current pick.
+    pipelines: {
+      label: 'Pipeline',
+      applyAria: 'Aplicar pipeline',
+      applyPrompt: 'Aplicar pipeline…',
+      empty: 'Nenhuma pipeline salva',
+      saveButton: 'Salvar como pipeline',
+      saveTitle: 'Salvar a seleção atual dos quatro slots como uma pipeline reutilizável',
+      namePlaceholder: 'Nome da pipeline',
+      nameAria: 'Novo nome de pipeline',
+      toast: {
+        applied: '"{{name}}" aplicada',
+        saved: 'Pipeline salva',
+        saveFailed: 'Falha ao salvar',
+      },
+    },
   },
 
   // Spoolman

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

@@ -1560,6 +1560,8 @@ export default {
       smartPlugs: 'Akıllı Prizler',
       notifications: 'Bildirimler',
       queue: 'İş Akışı',
+      queueDispatch: 'Kuyruk ve Sevkıyat',
+      queuePipelines: 'Pipeline\'lar',
       filament: 'Filament',
       network: 'Ağ',
       apiKeys: 'API Anahtarları',
@@ -2555,6 +2557,44 @@ export default {
       migrationErrorWarning: 'Başlangıçta {{count}} eski satır yeniden şifrelenemedi. Sunucu günlüklerini kontrol edin ve yeniden denemek için Bambuddy\'yi yeniden başlatın.',
     },
 
+
+    // Slicer Pipelines (#1425): list/edit/delete preset bundles users saved
+    // from the Slice dialog. Lives in Settings → Workflow → Pipelines sub-tab.
+    pipelines: {
+      title: 'Dilimleyici Pipeline\'ları',
+      subtitle: 'Yeniden kullanılabilir ön ayar paketleri (yazıcı + işlem + filamentler + tabla türü). Dilimleme diyalogundan bir tane kaydedin ve bir sonraki dosyaya tek tıkla uygulayın.',
+      loading: 'Pipeline\'lar yükleniyor…',
+      loadError: 'Pipeline\'lar yüklenemedi.',
+      confirmDelete: 'Bu pipeline silinsin mi? Bu işlem geri alınamaz.',
+      staleWarning: 'Atıfta bulunulan bir veya daha fazla ön ayar artık mevcut değil. Dilimleme diyalogundan bu pipeline\'ı yeniden kaydederek düzeltin.',
+      empty: {
+        title: 'Henüz pipeline yok.',
+        howto: 'Herhangi bir dosya için Dilimleme diyalogunu açın, yazıcı / işlem / filamentler / tablayı seçin, sonra "Pipeline olarak kaydet"e tıklayın. Kaydedilen pipeline\'larınız burada görünecek.',
+      },
+      field: {
+        name: 'Pipeline adı',
+        description: 'Açıklama',
+      },
+      action: {
+        save: 'Kaydet',
+        cancel: 'İptal',
+        rename: 'Yeniden adlandır',
+        delete: 'Sil',
+      },
+      slot: {
+        printer: 'Yazıcı',
+        process: 'İşlem',
+        filament: 'Filament',
+        filamentN: 'Filament {{n}}',
+        bed: 'Tabla',
+      },
+      toast: {
+        saved: 'Pipeline kaydedildi',
+        saveFailed: 'Kaydetme başarısız',
+        deleted: 'Pipeline silindi',
+        deleteFailed: 'Silme başarısız',
+      },
+    },
   },
 
   // Bildirimler (push bildirimleri için)
@@ -3834,6 +3874,22 @@ export default {
       texturedPEI: 'Dokulu PEI Plakası',
       smoothPEI: 'Düz PEI Plakası',
     },
+    // Slicer Pipelines (#1425) — apply a saved bundle or save the current pick.
+    pipelines: {
+      label: 'Pipeline',
+      applyAria: 'Pipeline uygula',
+      applyPrompt: 'Pipeline uygula…',
+      empty: 'Kayıtlı pipeline yok',
+      saveButton: 'Pipeline olarak kaydet',
+      saveTitle: 'Dört slotluk mevcut seçimi yeniden kullanılabilir pipeline olarak kaydet',
+      namePlaceholder: 'Pipeline adı',
+      nameAria: 'Yeni pipeline adı',
+      toast: {
+        applied: '"{{name}}" uygulandı',
+        saved: 'Pipeline kaydedildi',
+        saveFailed: 'Kaydetme başarısız',
+      },
+    },
   },
 
   // Spoolman

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

@@ -1558,6 +1558,8 @@ export default {
       smartPlugs: '智能插座',
       notifications: '通知',
       queue: '工作流',
+      queueDispatch: '队列与调度',
+      queuePipelines: '流水线',
       filament: '耗材',
       network: '网络',
       apiKeys: 'API 密钥',
@@ -2539,6 +2541,44 @@ export default {
       migrationErrorWarning: '{{count}} 行旧数据在启动时未能重新加密。请检查服务器日志并重启 Bambuddy 以重试。',
     },
 
+
+    // Slicer Pipelines (#1425): list/edit/delete preset bundles users saved
+    // from the Slice dialog. Lives in Settings → Workflow → Pipelines sub-tab.
+    pipelines: {
+      title: '切片机流水线',
+      subtitle: '可复用的预设捆绑包(打印机 + 工艺 + 耗材 + 热床类型)。在切片对话框中保存一个,下次切片文件时一键应用。',
+      loading: '正在加载流水线…',
+      loadError: '无法加载流水线。',
+      confirmDelete: '删除此流水线?此操作无法撤销。',
+      staleWarning: '引用的一个或多个预设已不存在。请在切片对话框中重新保存此流水线以修复。',
+      empty: {
+        title: '暂无流水线。',
+        howto: '为任意文件打开切片对话框,选择打印机 / 工艺 / 耗材 / 热床,然后点击"另存为流水线"。保存的流水线将显示在此处。',
+      },
+      field: {
+        name: '流水线名称',
+        description: '描述',
+      },
+      action: {
+        save: '保存',
+        cancel: '取消',
+        rename: '重命名',
+        delete: '删除',
+      },
+      slot: {
+        printer: '打印机',
+        process: '工艺',
+        filament: '耗材',
+        filamentN: '耗材 {{n}}',
+        bed: '热床',
+      },
+      toast: {
+        saved: '流水线已保存',
+        saveFailed: '保存失败',
+        deleted: '流水线已删除',
+        deleteFailed: '删除失败',
+      },
+    },
   },
 
   // Notifications (for push notifications)
@@ -3832,6 +3872,22 @@ export default {
       texturedPEI: 'Textured PEI Plate',
       smoothPEI: 'Smooth PEI Plate',
     },
+    // Slicer Pipelines (#1425) — apply a saved bundle or save the current pick.
+    pipelines: {
+      label: '流水线',
+      applyAria: '应用流水线',
+      applyPrompt: '应用流水线…',
+      empty: '无已保存流水线',
+      saveButton: '另存为流水线',
+      saveTitle: '将当前四个槽位的选择保存为可复用流水线',
+      namePlaceholder: '流水线名称',
+      nameAria: '新流水线名称',
+      toast: {
+        applied: '已应用 "{{name}}"',
+        saved: '流水线已保存',
+        saveFailed: '保存失败',
+      },
+    },
   },
 
   // Spoolman

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

@@ -1558,6 +1558,8 @@ export default {
       smartPlugs: '智慧插座',
       notifications: '通知',
       queue: '工作流程',
+      queueDispatch: '佇列與分派',
+      queuePipelines: '管線',
       filament: '耗材',
       network: '網路',
       apiKeys: 'API 金鑰',
@@ -2539,6 +2541,44 @@ export default {
       migrationErrorWarning: '{{count}} 行舊資料在啟動時未能重新加密。請檢查伺服器日誌並重新啟動 Bambuddy 以重試。',
     },
 
+
+    // Slicer Pipelines (#1425): list/edit/delete preset bundles users saved
+    // from the Slice dialog. Lives in Settings → Workflow → Pipelines sub-tab.
+    pipelines: {
+      title: '切片機管線',
+      subtitle: '可重複使用的預設組合(印表機 + 製程 + 耗材 + 熱床類型)。在切片對話框中儲存一個,下次切片檔案時一鍵套用。',
+      loading: '正在載入管線…',
+      loadError: '無法載入管線。',
+      confirmDelete: '刪除此管線?此操作無法復原。',
+      staleWarning: '參考的一個或多個預設已不存在。請在切片對話框中重新儲存此管線以修正。',
+      empty: {
+        title: '尚無管線。',
+        howto: '為任意檔案開啟切片對話框,選擇印表機 / 製程 / 耗材 / 熱床,然後點擊「另存為管線」。儲存的管線將顯示於此處。',
+      },
+      field: {
+        name: '管線名稱',
+        description: '描述',
+      },
+      action: {
+        save: '儲存',
+        cancel: '取消',
+        rename: '重新命名',
+        delete: '刪除',
+      },
+      slot: {
+        printer: '印表機',
+        process: '製程',
+        filament: '耗材',
+        filamentN: '耗材 {{n}}',
+        bed: '熱床',
+      },
+      toast: {
+        saved: '管線已儲存',
+        saveFailed: '儲存失敗',
+        deleted: '管線已刪除',
+        deleteFailed: '刪除失敗',
+      },
+    },
   },
 
   // Notifications (for push notifications)
@@ -3832,6 +3872,22 @@ export default {
       texturedPEI: 'Textured PEI Plate',
       smoothPEI: 'Smooth PEI Plate',
     },
+    // Slicer Pipelines (#1425) — apply a saved bundle or save the current pick.
+    pipelines: {
+      label: '管線',
+      applyAria: '套用管線',
+      applyPrompt: '套用管線…',
+      empty: '無已儲存管線',
+      saveButton: '另存為管線',
+      saveTitle: '將目前四個槽位的選擇儲存為可重複使用的管線',
+      namePlaceholder: '管線名稱',
+      nameAria: '新管線名稱',
+      toast: {
+        applied: '已套用「{{name}}」',
+        saved: '管線已儲存',
+        saveFailed: '儲存失敗',
+      },
+    },
   },
 
   // Spoolman

+ 55 - 3
frontend/src/pages/SettingsPage.tsx

@@ -1,5 +1,5 @@
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
-import { Loader2, Plus, Plug, AlertTriangle, RotateCcw, Bell, Download, RefreshCw, ExternalLink, Globe, Droplets, Thermometer, FileText, Edit2, Send, CheckCircle, XCircle, History, Trash2, Zap, TrendingUp, Calendar, DollarSign, Power, PowerOff, Key, Copy, Database, X, Shield, Printer, Cylinder, Wifi, Home, Video, Users, Lock, Unlock, ChevronDown, Save, Mail, Flame, Layers, ListOrdered, Code, Search, Scale, Settings as SettingsIcon, ScanEye, Cog, QrCode, Heart } from 'lucide-react';
+import { Loader2, Plus, Plug, AlertTriangle, RotateCcw, Bell, Download, RefreshCw, ExternalLink, Globe, Droplets, Thermometer, FileText, Edit2, Send, CheckCircle, XCircle, History, Trash2, Zap, TrendingUp, Calendar, DollarSign, Power, PowerOff, Key, Copy, Database, X, Shield, Printer, Cylinder, Wifi, Home, Video, Users, Lock, Unlock, ChevronDown, Save, Mail, Flame, Layers, ListOrdered, Code, Search, Scale, Settings as SettingsIcon, ScanEye, Cog, QrCode, Heart, Workflow } from 'lucide-react';
 import { useTranslation } from 'react-i18next';
 import { useNavigate, useSearchParams } from 'react-router-dom';
 import { api } from '../api/client';
@@ -11,6 +11,7 @@ import { PRESET_CATEGORIES, parsePresetTriple } from '../utils/temperatureFanPre
 import type { APIKey, AppSettings, AppSettingsUpdate, SmartPlug, SmartPlugStatus, NotificationProvider, NotificationTemplate, UpdateStatus, GitHubBackupStatus, CloudAuthStatus, UserCreate, UserUpdate, UserResponse, StorageUsageResponse } from '../api/client';
 import { Card, CardContent, CardDensityProvider, CardHeader } from '../components/Card';
 import { SlicerBundlesPanel } from '../components/SlicerBundlesPanel';
+import { SlicerPipelinesPanel } from '../components/SlicerPipelinesPanel';
 import { CameraTokensSection } from './CameraTokensPage';
 import { Collapsible } from '../components/Collapsible';
 import { Button } from '../components/Button';
@@ -190,6 +191,11 @@ export function SettingsPage() {
   const initialTab = isLegacyEmailTab ? 'users' : (tabParam && validTabs.includes(tabParam as TabType) ? tabParam as TabType : 'general');
   const [activeTab, setActiveTab] = useState<TabType>(initialTab);
   const [usersSubTab, setUsersSubTab] = useState<UsersSubTab>(isLegacyEmailTab ? 'email' : 'users');
+  // Workflow tab sub-tabs (#1425): 'dispatch' = current Workflow content,
+  // 'pipelines' = Slicer Pipelines management. URL: ?tab=queue&sub=pipelines.
+  const initialQueueSub: 'dispatch' | 'pipelines' =
+    tabParam === 'queue' && searchParams.get('sub') === 'pipelines' ? 'pipelines' : 'dispatch';
+  const [queueSubTab, setQueueSubTab] = useState<'dispatch' | 'pipelines'>(initialQueueSub);
 
   // Update URL when tab changes
   const handleTabChange = (tab: TabType) => {
@@ -197,6 +203,10 @@ export function SettingsPage() {
     if (tab === 'users') {
       setUsersSubTab('users');
     }
+    if (tab === 'queue') {
+      setQueueSubTab('dispatch');
+      searchParams.delete('sub');
+    }
     if (tab === 'general') {
       searchParams.delete('tab');
     } else {
@@ -204,6 +214,17 @@ export function SettingsPage() {
     }
     setSearchParams(searchParams, { replace: true });
   };
+
+  // Switch the Workflow tab's sub-tab and reflect it in the URL so deep-links work.
+  const handleQueueSubTabChange = (sub: 'dispatch' | 'pipelines') => {
+    setQueueSubTab(sub);
+    if (sub === 'pipelines') {
+      searchParams.set('sub', 'pipelines');
+    } else {
+      searchParams.delete('sub');
+    }
+    setSearchParams(searchParams, { replace: true });
+  };
   const [showCreateAPIKey, setShowCreateAPIKey] = useState(false);
   const [newAPIKeyName, setNewAPIKeyName] = useState('');
   const [newAPIKeyPermissions, setNewAPIKeyPermissions] = useState({
@@ -4070,8 +4091,37 @@ export function SettingsPage() {
       )}
 
       {/* Filament Tab */}
-      {/* Queue Tab */}
-      {activeTab === 'queue' && localSettings && (
+      {/* Queue Tab — sub-tabs: Queue & Dispatch (current content) / Pipelines (#1425) */}
+      {activeTab === 'queue' && (
+        <div className="space-y-3">
+          {/* Sub-tab nav, mirroring the Authentication tab pattern */}
+          <div className="flex gap-1 border-b border-bambu-dark-tertiary">
+            <button
+              onClick={() => handleQueueSubTabChange('dispatch')}
+              className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px flex items-center gap-2 ${
+                queueSubTab === 'dispatch'
+                  ? 'text-bambu-green border-bambu-green'
+                  : 'text-bambu-gray hover:text-gray-900 dark:hover:text-white border-transparent'
+              }`}
+            >
+              <ListOrdered className="w-4 h-4" />
+              {t('settings.tabs.queueDispatch', 'Queue & Dispatch')}
+            </button>
+            <button
+              onClick={() => handleQueueSubTabChange('pipelines')}
+              className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px flex items-center gap-2 ${
+                queueSubTab === 'pipelines'
+                  ? 'text-bambu-green border-bambu-green'
+                  : 'text-bambu-gray hover:text-gray-900 dark:hover:text-white border-transparent'
+              }`}
+            >
+              <Workflow className="w-4 h-4" />
+              {t('settings.tabs.queuePipelines', 'Pipelines')}
+            </button>
+          </div>
+
+          {queueSubTab === 'pipelines' && <SlicerPipelinesPanel />}
+          {queueSubTab === 'dispatch' && localSettings && (
         <div className="flex flex-col lg:flex-row gap-4 lg:gap-6">
           {/* Left Column */}
           <div className="lg:w-1/2 space-y-3">
@@ -4798,6 +4848,8 @@ export function SettingsPage() {
           </Card>
           </div>
         </div>
+          )}
+        </div>
       )}
 
       {activeTab === 'filament' && localSettings && (

Fichier diff supprimé car celui-ci est trop grand
+ 0 - 1
static/assets/index-Bxs6ZGEZ.css


Fichier diff supprimé car celui-ci est trop grand
+ 1 - 0
static/assets/index-CcI7-4jx.css


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/index-D51oc-O3.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-B1_XGLrL.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-Bxs6ZGEZ.css">
+    <script type="module" crossorigin src="/assets/index-D51oc-O3.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-CcI7-4jx.css">
   </head>
   <body>
     <div id="root"></div>

Certains fichiers n'ont pas été affichés car il y a eu trop de fichiers modifiés dans ce diff