Explorar o código

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 hai 2 meses
pai
achega
d6bdb7e200

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 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,
         Permission.SMART_PLUGS_DELETE,
         # Network scanning — operator only (no API-key scope for this).
         # Network scanning — operator only (no API-key scope for this).
         Permission.DISCOVERY_SCAN,
         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,
         project_bom,
         settings,
         settings,
         shopping_list,
         shopping_list,
+        slicer_pipeline,
         slot_preset,
         slot_preset,
         smart_plug,
         smart_plug,
         smart_plug_energy_snapshot,
         smart_plug_energy_snapshot,
@@ -3594,6 +3595,38 @@ async def seed_default_groups():
                 group.permissions = perms
                 group.permissions = perms
         await session.commit()
         await session.commit()
 
 
+        # Backfill pipeline permissions (#1425). Pipelines were added after
+        # initial seeding, so existing groups need them appended:
+        #   - Administrators: all three (matches fresh-install ALL_PERMISSIONS)
+        #   - 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
         # Migrate existing users to groups if they're not already in any group
         if groups_created:
         if groups_created:
             # Refresh to get newly created groups
             # 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_UPDATE = "groups:update"
     GROUPS_DELETE = "groups:delete"
     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 connection
     WEBSOCKET_CONNECT = "websocket:connect"
     WEBSOCKET_CONNECT = "websocket:connect"
 
 
@@ -337,6 +342,11 @@ PERMISSION_CATEGORIES = {
         Permission.GROUPS_UPDATE,
         Permission.GROUPS_UPDATE,
         Permission.GROUPS_DELETE,
         Permission.GROUPS_DELETE,
     ],
     ],
+    "Slicer Pipelines": [
+        Permission.PIPELINES_READ,
+        Permission.PIPELINES_WRITE,
+        Permission.PIPELINES_RUN,
+    ],
     "WebSocket": [
     "WebSocket": [
         Permission.WEBSOCKET_CONNECT,
         Permission.WEBSOCKET_CONNECT,
     ],
     ],
@@ -452,6 +462,10 @@ DEFAULT_GROUPS = {
             Permission.SYSTEM_READ.value,
             Permission.SYSTEM_READ.value,
             # Settings - read only
             # Settings - read only
             Permission.SETTINGS_READ.value,
             Permission.SETTINGS_READ.value,
+            # Slicer Pipelines - full access
+            Permission.PIPELINES_READ.value,
+            Permission.PIPELINES_WRITE.value,
+            Permission.PIPELINES_RUN.value,
             # WebSocket
             # WebSocket
             Permission.WEBSOCKET_CONNECT.value,
             Permission.WEBSOCKET_CONNECT.value,
         ],
         ],
@@ -483,6 +497,8 @@ DEFAULT_GROUPS = {
             Permission.STATS_READ.value,
             Permission.STATS_READ.value,
             Permission.SYSTEM_READ.value,
             Permission.SYSTEM_READ.value,
             Permission.SETTINGS_READ.value,
             Permission.SETTINGS_READ.value,
+            # Slicer Pipelines - read only
+            Permission.PIPELINES_READ.value,
             Permission.WEBSOCKET_CONNECT.value,
             Permission.WEBSOCKET_CONNECT.value,
             # MakerWorld browsing only (no import — that writes to library)
             # MakerWorld browsing only (no import — that writes to library)
             Permission.MAKERWORLD_VIEW.value,
             Permission.MAKERWORLD_VIEW.value,

+ 2 - 0
backend/app/main.py

@@ -56,6 +56,7 @@ from backend.app.api.routes import (
     projects,
     projects,
     settings as settings_routes,
     settings as settings_routes,
     slice_jobs,
     slice_jobs,
+    slicer_pipelines,
     slicer_presets,
     slicer_presets,
     smart_plugs,
     smart_plugs,
     sponsor_prompt,
     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_tags.router, prefix=app_settings.api_prefix)
 app.include_router(library_trash.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(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(slicer_presets.router, prefix=app_settings.api_prefix)
 app.include_router(archive_purge.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)
 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.printer_sensor_history import PrinterSensorHistory
 from backend.app.models.project import Project
 from backend.app.models.project import Project
 from backend.app.models.settings import Settings
 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 import SmartPlug
 from backend.app.models.smart_plug_energy_snapshot import SmartPlugEnergySnapshot
 from backend.app.models.smart_plug_energy_snapshot import SmartPlugEnergySnapshot
 from backend.app.models.sponsor_toast_state import SponsorToastState
 from backend.app.models.sponsor_toast_state import SponsorToastState
@@ -69,6 +70,7 @@ __all__ = [
     "OIDCProvider",
     "OIDCProvider",
     "UserOIDCLink",
     "UserOIDCLink",
     "OrcaBaseProfile",
     "OrcaBaseProfile",
+    "SlicerPipeline",
     "Spool",
     "Spool",
     "SpoolKProfile",
     "SpoolKProfile",
     "SpoolAssignment",
     "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',
   'Pause', 'Power', 'System', 'Problem', 'Designer', 'Extruder', 'Firmware',
   'Material', 'Original', 'Position', 'Webhook', 'Workflow', 'Slicer',
   'Material', 'Original', 'Position', 'Webhook', 'Workflow', 'Slicer',
+  'Pipeline', 'Pipelines', 'Filament {{n}}',  // #1425 — Slicer Pipelines (DE)
   'Region', 'Normal', 'Orange', 'Branch', 'Budget', 'Commit', 'Global',
   'Region', 'Normal', 'Orange', 'Branch', 'Budget', 'Commit', 'Global',
   'Version', 'Slot', 'Live', 'Rate', 'Host', 'Trend', 'Min', 'Admin', 'Cloud',
   'Version', 'Slot', 'Live', 'Rate', 'Host', 'Trend', 'Min', 'Admin', 'Cloud',
   'Filament', 'Filaments', 'Software', 'Hardware', 'Avatar', 'Pin', 'Modal',
   'Filament', 'Filaments', 'Software', 'Hardware', 'Avatar', 'Pin', 'Modal',
@@ -179,6 +180,7 @@ const FR_COGNATES = [
   'Filaments', 'Software', 'Hardware', 'Stop', 'Reset', 'Test', 'Code',
   'Filaments', 'Software', 'Hardware', 'Stop', 'Reset', 'Test', 'Code',
   'Token', 'Server', 'Port', 'Plate', 'Layer', 'Active', 'Total', 'Avatar',
   'Token', 'Server', 'Port', 'Plate', 'Layer', 'Active', 'Total', 'Avatar',
   'Job', 'Modal', 'Pin', 'Pro', 'Mini', 'Studio', 'Excellent', 'Description',
   'Job', 'Modal', 'Pin', 'Pro', 'Mini', 'Studio', 'Excellent', 'Description',
+  'Pipeline', 'Pipelines', 'Filament {{n}}',  // #1425 — Slicer Pipelines (FR)
   'Action', 'Actions', 'Date', 'Type', 'Cache', 'Service', 'Configuration',
   'Action', 'Actions', 'Date', 'Type', 'Cache', 'Service', 'Configuration',
   'Archives', 'Maintenance', 'Notifications', 'Notification', 'Position',
   'Archives', 'Maintenance', 'Notifications', 'Notification', 'Position',
   'Pause', 'Solution', 'Source', 'Version', 'Format', 'Documentation',
   'Pause', 'Solution', 'Source', 'Version', 'Format', 'Documentation',
@@ -217,6 +219,7 @@ const IT_COGNATES = [
   'Bambu Cloud', 'Orca Cloud',  // brand names — same in every locale
   'Bambu Cloud', 'Orca Cloud',  // brand names — same in every locale
   'AMS Filament Backup',  // Bambu Lab product/firmware feature name
   'AMS Filament Backup',  // Bambu Lab product/firmware feature name
   'Email',  // common loanword in Italian, used verbatim in UI labels
   'Email',  // common loanword in Italian, used verbatim in UI labels
+  'Pipeline',  // #1425 — Slicer Pipelines (cognate in IT)
   'Status', 'Tag', 'Tags', 'Online', 'Offline', 'Standard', 'Filament',
   'Status', 'Tag', 'Tags', 'Online', 'Offline', 'Standard', 'Filament',
   'Filaments', 'Software', 'Hardware', 'Stop', 'Reset', 'Test', 'Code',
   'Filaments', 'Software', 'Hardware', 'Stop', 'Reset', 'Test', 'Code',
   'Token', 'Server', 'Port', 'Plate', 'Layer', 'Modal', 'Pin', 'Pro', 'Mini',
   'Token', 'Server', 'Port', 'Plate', 'Layer', 'Modal', 'Pin', 'Pro', 'Mini',
@@ -260,6 +263,7 @@ const JA_COGNATES = [
 const PT_BR_COGNATES = [
 const PT_BR_COGNATES = [
   'Bambu Cloud', 'Orca Cloud',  // brand names — same in every locale
   'Bambu Cloud', 'Orca Cloud',  // brand names — same in every locale
   'AMS Filament Backup',  // Bambu Lab product/firmware feature name
   'AMS Filament Backup',  // Bambu Lab product/firmware feature name
+  'Pipeline', 'Pipelines',  // #1425 — Slicer Pipelines (PT-BR)
   'Status', 'Tag', 'Tags', 'Online', 'Offline', 'Standard', 'Filament',
   'Status', 'Tag', 'Tags', 'Online', 'Offline', 'Standard', 'Filament',
   'Software', 'Hardware', 'Stop', 'Reset', 'Test', 'Code', 'Token', 'Server',
   'Software', 'Hardware', 'Stop', 'Reset', 'Test', 'Code', 'Token', 'Server',
   'Port', 'Plate', 'Layer', 'Modal', 'Pin', 'Pro', 'Mini', 'Studio', 'Cache',
   'Port', 'Plate', 'Layer', 'Modal', 'Pin', 'Pro', 'Mini', 'Studio', 'Cache',
@@ -332,6 +336,7 @@ const KO_COGNATES = [
 const ES_COGNATES = [
 const ES_COGNATES = [
   'Bambu Cloud', 'Orca Cloud',  // brand names — same in every locale
   'Bambu Cloud', 'Orca Cloud',  // brand names — same in every locale
   'AMS Filament Backup',  // Bambu Lab product/firmware feature name
   'AMS Filament Backup',  // Bambu Lab product/firmware feature name
+  'Pipeline', 'Pipelines',  // #1425 — Slicer Pipelines (ES)
   'Error', 'Firmware', 'General', 'Control', 'Total', 'total', 'Material',
   'Error', 'Firmware', 'General', 'Control', 'Total', 'total', 'Material',
   'Material:', 'Color', 'Hex', 'Local', 'Global', 'China', 'Editable',
   'Material:', 'Color', 'Hex', 'Local', 'Global', 'China', 'Editable',
   'Normal', 'Metal', 'Multicolor', 'Proxy', 'Host', 'Factor', 'Original',
   'Normal', 'Metal', 'Multicolor', 'Proxy', 'Host', 'Factor', 'Original',
@@ -353,6 +358,7 @@ const TR_COGNATES = [
   'Filament', 'Firmware', 'Disk', 'Hex', 'Test', 'Port', 'Model', 'Metal',
   'Filament', 'Firmware', 'Disk', 'Hex', 'Test', 'Port', 'Model', 'Metal',
   'Bambu Cloud', 'Orca Cloud',  // brand names — same in every locale
   'Bambu Cloud', 'Orca Cloud',  // brand names — same in every locale
   'AMS Filament Backup',  // Bambu Lab product/firmware feature name
   'AMS Filament Backup',  // Bambu Lab product/firmware feature name
+  'Pipeline', 'Filament {{n}}',  // #1425 — Slicer Pipelines (TR)
   'Min', 'Normal', 'Platform', 'Net', 'Trend', 'Commit', 'Global', 'Proxy',
   'Min', 'Normal', 'Platform', 'Net', 'Trend', 'Commit', 'Global', 'Proxy',
   'N/A', 'email',
   'N/A', 'email',
   'STARTTLS (Port 587)', 'SSL/TLS (Port 465)',
   '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(),
     getArchiveFilamentRequirements: vi.fn(),
     getSettings: vi.fn().mockResolvedValue({}),
     getSettings: vi.fn().mockResolvedValue({}),
     updateSettings: 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>;
   getArchivePlates: ReturnType<typeof vi.fn>;
   getLibraryFileFilamentRequirements: ReturnType<typeof vi.fn>;
   getLibraryFileFilamentRequirements: ReturnType<typeof vi.fn>;
   getArchiveFilamentRequirements: 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 {
 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', () => {
 describe('SliceModal', () => {
   beforeEach(() => {
   beforeEach(() => {
     vi.clearAllMocks();
     vi.clearAllMocks();
@@ -121,6 +136,8 @@ describe('SliceModal', () => {
       plate_id: 1,
       plate_id: 1,
       filaments: [],
       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 () => {
   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
     // 4 selects: printer, process, bed-type (#1337), filament. bed-type sits
     // between process and filament — it overrides curr_bed_type on the
     // between process and filament — it overrides curr_bed_type on the
     // process preset so the related controls cluster — and defaults to "".
     // 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).toHaveLength(4);
     expect(selects[0].value).toBe('local:1');
     expect(selects[0].value).toBe('local:1');
     expect(selects[1].value).toBe('local:2');
     expect(selects[1].value).toBe('local:2');
@@ -158,7 +175,7 @@ describe('SliceModal', () => {
 
 
     await waitFor(() => expect(screen.getByText('Imported X1C 0.4')).toBeDefined());
     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');
     const groups = printerSelect.querySelectorAll('optgroup');
     expect(Array.from(groups).map((g) => g.label)).toEqual([
     expect(Array.from(groups).map((g) => g.label)).toEqual([
       'Imported',
       'Imported',
@@ -190,7 +207,7 @@ describe('SliceModal', () => {
     });
     });
 
 
     await waitFor(() => expect(screen.getByText('Imported X1C 0.4')).toBeDefined());
     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');
     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());
     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');
     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
     // printer (0), process (1), bed-type (2), filament (3+). Find the
     // bed-type select by name rather than positional index so this stays
     // bed-type select by name rather than positional index so this stays
     // green if the layout adds another control around it.
     // 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'),
       (el as HTMLSelectElement).options[0]?.textContent?.toLowerCase().includes('auto'),
     ) as HTMLSelectElement;
     ) as HTMLSelectElement;
     expect(bedSelect).toBeDefined();
     expect(bedSelect).toBeDefined();
@@ -315,7 +332,7 @@ describe('SliceModal', () => {
     await waitFor(() => expect(screen.getByText('My Custom X1C')).toBeDefined());
     await waitFor(() => expect(screen.getByText('My Custom X1C')).toBeDefined());
 
 
     const user = userEvent.setup();
     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.selectOptions(selects[0], 'standard:Bambu Lab X1 Carbon 0.4 nozzle');
     await user.click(screen.getByRole('button', { name: /^Slice$/ }));
     await user.click(screen.getByRole('button', { name: /^Slice$/ }));
 
 
@@ -731,7 +748,7 @@ describe('SliceModal', () => {
 
 
     await waitFor(() => expect(screen.getByText('X1C')).toBeDefined());
     await waitFor(() => expect(screen.getByText('X1C')).toBeDefined());
     // 1 printer + 1 process + 2 filament + 1 bed-type (#1337) = 5 dropdowns.
     // 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 () => {
   it('pre-picks each filament slot by matching colour metadata', async () => {
@@ -812,7 +829,7 @@ describe('SliceModal', () => {
     await waitFor(() => expect(screen.getByText('X1C')).toBeDefined());
     await waitFor(() => expect(screen.getByText('X1C')).toBeDefined());
 
 
     const user = userEvent.setup();
     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
     // Order: 0 printer, 1 process, 2 bed-type, 3 filament-1, 4 filament-2
     // (#1337). Auto-picks land on printer/process/filaments; bed-type
     // (#1337). Auto-picks land on printer/process/filaments; bed-type
     // defaults to "". Swap filament-1 (index 3) from the auto-picked black
     // 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 +
     // Both filament rows render — 1 printer + 1 process + 1 bed-type +
     // 2 filament (#1337) = 5. bed-type sits at index 2, filament slots
     // 2 filament (#1337) = 5. bed-type sits at index 2, filament slots
     // follow at 3 and 4.
     // follow at 3 and 4.
-    const selects = screen.getAllByRole('combobox') as HTMLSelectElement[];
+    const selects = presetSelects();
     expect(selects).toHaveLength(5);
     expect(selects).toHaveLength(5);
     // Slot 1 (used) is editable, slot 2 (not used) is disabled.
     // Slot 1 (used) is editable, slot 2 (not used) is disabled.
     expect(selects[3].disabled).toBe(false);
     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;
   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 {
 export interface SliceResponse {
   library_file_id: number;
   library_file_id: number;
   name: string;
   name: string;
@@ -6199,6 +6232,25 @@ export const api = {
       options?.refresh ? '/slicer/presets?refresh=true' : '/slicer/presets',
       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.
   // Canonical Bambu printer-model registry — "Bambu Lab <model>" → short code.
   // Single source of truth shared with backend (PRINTER_MODEL_MAP); the
   // Single source of truth shared with backend (PRINTER_MODEL_MAP); the
   // SliceModal uses this to classify cloud / standard presets by their
   // 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.
   // user had no way to switch plates without cloning the preset.
   const [bedType, setBedType] = useState<string | null>(null);
   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({
   const platesQuery = useQuery({
     queryKey: ['slicePlates', source.kind, source.id],
     queryKey: ['slicePlates', source.kind, source.id],
     queryFn: async () => {
     queryFn: async () => {
@@ -682,6 +711,122 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
                   status === 'ok' (returns null in that case), but the Refresh
                   status === 'ok' (returns null in that case), but the Refresh
                   button stays visible regardless so users can pick up cloud /
                   button stays visible regardless so users can pick up cloud /
                   bundled changes even when sign-in is healthy. */}
                   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
               <PresetDropdown
                 label={t('slice.printer')}
                 label={t('slice.printer')}
                 slot="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',
       smartPlugs: 'Smart Plugs',
       notifications: 'Benachrichtigungen',
       notifications: 'Benachrichtigungen',
       queue: 'Workflow',
       queue: 'Workflow',
+      queueDispatch: 'Warteschlange & Dispatch',
+      queuePipelines: 'Pipelines',
       filament: 'Filament',
       filament: 'Filament',
       network: 'Netzwerk',
       network: 'Netzwerk',
       apiKeys: 'API-Schlüssel',
       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.',
       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)
   // Notifications (for push notifications)
@@ -3844,6 +3884,22 @@ export default {
       texturedPEI: 'Textured PEI Plate',
       texturedPEI: 'Textured PEI Plate',
       smoothPEI: 'Smooth 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
   // Spoolman

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

@@ -1572,6 +1572,8 @@ export default {
       smartPlugs: 'Smart Plugs',
       smartPlugs: 'Smart Plugs',
       notifications: 'Notifications',
       notifications: 'Notifications',
       queue: 'Workflow',
       queue: 'Workflow',
+      queueDispatch: 'Queue & Dispatch',
+      queuePipelines: 'Pipelines',
       filament: 'Filament',
       filament: 'Filament',
       network: 'Network',
       network: 'Network',
       apiKeys: 'API Keys',
       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.',
       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)
   // Notifications (for push notifications)
@@ -3861,6 +3900,22 @@ export default {
       texturedPEI: 'Textured PEI Plate',
       texturedPEI: 'Textured PEI Plate',
       smoothPEI: 'Smooth 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
   // Spoolman

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

@@ -1559,6 +1559,8 @@ export default {
       smartPlugs: 'Enchufes inteligentes',
       smartPlugs: 'Enchufes inteligentes',
       notifications: 'Notificaciones',
       notifications: 'Notificaciones',
       queue: 'Flujo de trabajo',
       queue: 'Flujo de trabajo',
+      queueDispatch: 'Cola y Despacho',
+      queuePipelines: 'Pipelines',
       filament: 'Filamento',
       filament: 'Filamento',
       network: 'Red',
       network: 'Red',
       apiKeys: 'Claves API',
       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.',
       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)
   // Notifications (for push notifications)
@@ -3847,6 +3887,22 @@ export default {
       texturedPEI: 'Textured PEI Plate',
       texturedPEI: 'Textured PEI Plate',
       smoothPEI: 'Smooth 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
   // Spoolman

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

@@ -1558,6 +1558,8 @@ export default {
       smartPlugs: 'Prises connectées',
       smartPlugs: 'Prises connectées',
       notifications: 'Notifications',
       notifications: 'Notifications',
       queue: 'Flux de travail',
       queue: 'Flux de travail',
+      queueDispatch: 'File & Distribution',
+      queuePipelines: 'Pipelines',
       filament: 'Filament',
       filament: 'Filament',
       network: 'Réseau',
       network: 'Réseau',
       apiKeys: 'Clés API',
       apiKeys: 'Clés API',
@@ -2540,6 +2542,44 @@ export default {
       commandQueued: 'Commande en file d\'attente',
       commandQueued: 'Commande en file d\'attente',
       commandError: 'Échec de l\'envoi de la commande',
       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)
   // Notifications (for push notifications)
@@ -3833,6 +3873,22 @@ export default {
       texturedPEI: 'Textured PEI Plate',
       texturedPEI: 'Textured PEI Plate',
       smoothPEI: 'Smooth 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
   // Spoolman

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

@@ -1558,6 +1558,8 @@ export default {
       smartPlugs: 'Prese smart',
       smartPlugs: 'Prese smart',
       notifications: 'Notifiche',
       notifications: 'Notifiche',
       queue: 'Flusso',
       queue: 'Flusso',
+      queueDispatch: 'Coda e Dispatch',
+      queuePipelines: 'Pipeline',
       filament: 'Filamento',
       filament: 'Filamento',
       network: 'Rete',
       network: 'Rete',
       apiKeys: 'Chiavi API',
       apiKeys: 'Chiavi API',
@@ -2539,6 +2541,44 @@ export default {
       commandQueued: 'Comando in coda',
       commandQueued: 'Comando in coda',
       commandError: 'Invio comando non riuscito',
       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)
   // Notifications (for push notifications)
@@ -3832,6 +3872,22 @@ export default {
       texturedPEI: 'Textured PEI Plate',
       texturedPEI: 'Textured PEI Plate',
       smoothPEI: 'Smooth 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
   // Spoolman

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

@@ -1557,6 +1557,8 @@ export default {
       smartPlugs: 'スマートプラグ',
       smartPlugs: 'スマートプラグ',
       notifications: '通知',
       notifications: '通知',
       queue: 'ワークフロー',
       queue: 'ワークフロー',
+      queueDispatch: 'キューとディスパッチ',
+      queuePipelines: 'パイプライン',
       filament: 'フィラメント',
       filament: 'フィラメント',
       network: 'ネットワーク',
       network: 'ネットワーク',
       apiKeys: 'APIキー',
       apiKeys: 'APIキー',
@@ -2551,6 +2553,44 @@ export default {
       migrationErrorWarning: '{{count}} 件のレガシー行を起動時に再暗号化できませんでした。サーバーログを確認し、Bambuddy を再起動して再試行してください。',
       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)
   // Notifications (for push notifications)
@@ -3844,6 +3884,22 @@ export default {
       texturedPEI: 'Textured PEI Plate',
       texturedPEI: 'Textured PEI Plate',
       smoothPEI: 'Smooth 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
   // Spoolman

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

@@ -1472,6 +1472,8 @@ export default {
       smartPlugs: '스마트 플러그',
       smartPlugs: '스마트 플러그',
       notifications: '알림',
       notifications: '알림',
       queue: '워크플로우',
       queue: '워크플로우',
+      queueDispatch: '큐 및 디스패치',
+      queuePipelines: '파이프라인',
       filament: '필라멘트',
       filament: '필라멘트',
       network: '네트워크',
       network: '네트워크',
       apiKeys: 'API 키',
       apiKeys: 'API 키',
@@ -2406,7 +2408,45 @@ export default {
     updateEnergyCost: '전기 요금 업데이트',
     updateEnergyCost: '전기 요금 업데이트',
     updateEnergyCostDescription: '이 키가 /settings/electricity-price에 새 kWh당 전기 요금을 POST할 수 있도록 허용합니다. Home Assistant 동적 요금 자동화(Tibber, Octopus 등)에 유용합니다. API 키로 쓸 수 있는 유일한 설정 필드입니다.',
     updateEnergyCostDescription: '이 키가 /settings/electricity-price에 새 kWh당 전기 요금을 POST할 수 있도록 허용합니다. Home Assistant 동적 요금 자동화(Tibber, Octopus 등)에 유용합니다. API 키로 쓸 수 있는 유일한 설정 필드입니다.',
     energyCostBadge: '에너지',
     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: {
   notification: {
     printStarted: {
     printStarted: {
@@ -3636,7 +3676,23 @@ export default {
       highTemp: '고온 플레이트',
       highTemp: '고온 플레이트',
       texturedPEI: '텍스처 PEI 플레이트',
       texturedPEI: '텍스처 PEI 플레이트',
       smoothPEI: '매끄러운 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: {
   spoolman: {
     title: 'Spoolman 통합',
     title: 'Spoolman 통합',

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

@@ -1558,6 +1558,8 @@ export default {
       smartPlugs: 'Tomadas Inteligentes',
       smartPlugs: 'Tomadas Inteligentes',
       notifications: 'Notificações',
       notifications: 'Notificações',
       queue: 'Fluxo',
       queue: 'Fluxo',
+      queueDispatch: 'Fila e Dispatch',
+      queuePipelines: 'Pipelines',
       filament: 'Filamento',
       filament: 'Filamento',
       network: 'Rede',
       network: 'Rede',
       apiKeys: 'Chaves API',
       apiKeys: 'Chaves API',
@@ -2539,6 +2541,44 @@ export default {
       commandQueued: 'Comando enfileirado',
       commandQueued: 'Comando enfileirado',
       commandError: 'Falha ao enviar comando',
       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)
   // Notifications (for push notifications)
@@ -3832,6 +3872,22 @@ export default {
       texturedPEI: 'Textured PEI Plate',
       texturedPEI: 'Textured PEI Plate',
       smoothPEI: 'Smooth 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
   // Spoolman

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

@@ -1560,6 +1560,8 @@ export default {
       smartPlugs: 'Akıllı Prizler',
       smartPlugs: 'Akıllı Prizler',
       notifications: 'Bildirimler',
       notifications: 'Bildirimler',
       queue: 'İş Akışı',
       queue: 'İş Akışı',
+      queueDispatch: 'Kuyruk ve Sevkıyat',
+      queuePipelines: 'Pipeline\'lar',
       filament: 'Filament',
       filament: 'Filament',
       network: 'Ağ',
       network: 'Ağ',
       apiKeys: 'API Anahtarları',
       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.',
       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)
   // Bildirimler (push bildirimleri için)
@@ -3834,6 +3874,22 @@ export default {
       texturedPEI: 'Dokulu PEI Plakası',
       texturedPEI: 'Dokulu PEI Plakası',
       smoothPEI: 'Düz 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
   // Spoolman

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

@@ -1558,6 +1558,8 @@ export default {
       smartPlugs: '智能插座',
       smartPlugs: '智能插座',
       notifications: '通知',
       notifications: '通知',
       queue: '工作流',
       queue: '工作流',
+      queueDispatch: '队列与调度',
+      queuePipelines: '流水线',
       filament: '耗材',
       filament: '耗材',
       network: '网络',
       network: '网络',
       apiKeys: 'API 密钥',
       apiKeys: 'API 密钥',
@@ -2539,6 +2541,44 @@ export default {
       migrationErrorWarning: '{{count}} 行旧数据在启动时未能重新加密。请检查服务器日志并重启 Bambuddy 以重试。',
       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)
   // Notifications (for push notifications)
@@ -3832,6 +3872,22 @@ export default {
       texturedPEI: 'Textured PEI Plate',
       texturedPEI: 'Textured PEI Plate',
       smoothPEI: 'Smooth 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
   // Spoolman

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

@@ -1558,6 +1558,8 @@ export default {
       smartPlugs: '智慧插座',
       smartPlugs: '智慧插座',
       notifications: '通知',
       notifications: '通知',
       queue: '工作流程',
       queue: '工作流程',
+      queueDispatch: '佇列與分派',
+      queuePipelines: '管線',
       filament: '耗材',
       filament: '耗材',
       network: '網路',
       network: '網路',
       apiKeys: 'API 金鑰',
       apiKeys: 'API 金鑰',
@@ -2539,6 +2541,44 @@ export default {
       migrationErrorWarning: '{{count}} 行舊資料在啟動時未能重新加密。請檢查伺服器日誌並重新啟動 Bambuddy 以重試。',
       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)
   // Notifications (for push notifications)
@@ -3832,6 +3872,22 @@ export default {
       texturedPEI: 'Textured PEI Plate',
       texturedPEI: 'Textured PEI Plate',
       smoothPEI: 'Smooth 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
   // Spoolman

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

@@ -1,5 +1,5 @@
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
 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 { useTranslation } from 'react-i18next';
 import { useNavigate, useSearchParams } from 'react-router-dom';
 import { useNavigate, useSearchParams } from 'react-router-dom';
 import { api } from '../api/client';
 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 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 { Card, CardContent, CardDensityProvider, CardHeader } from '../components/Card';
 import { SlicerBundlesPanel } from '../components/SlicerBundlesPanel';
 import { SlicerBundlesPanel } from '../components/SlicerBundlesPanel';
+import { SlicerPipelinesPanel } from '../components/SlicerPipelinesPanel';
 import { CameraTokensSection } from './CameraTokensPage';
 import { CameraTokensSection } from './CameraTokensPage';
 import { Collapsible } from '../components/Collapsible';
 import { Collapsible } from '../components/Collapsible';
 import { Button } from '../components/Button';
 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 initialTab = isLegacyEmailTab ? 'users' : (tabParam && validTabs.includes(tabParam as TabType) ? tabParam as TabType : 'general');
   const [activeTab, setActiveTab] = useState<TabType>(initialTab);
   const [activeTab, setActiveTab] = useState<TabType>(initialTab);
   const [usersSubTab, setUsersSubTab] = useState<UsersSubTab>(isLegacyEmailTab ? 'email' : 'users');
   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
   // Update URL when tab changes
   const handleTabChange = (tab: TabType) => {
   const handleTabChange = (tab: TabType) => {
@@ -197,6 +203,10 @@ export function SettingsPage() {
     if (tab === 'users') {
     if (tab === 'users') {
       setUsersSubTab('users');
       setUsersSubTab('users');
     }
     }
+    if (tab === 'queue') {
+      setQueueSubTab('dispatch');
+      searchParams.delete('sub');
+    }
     if (tab === 'general') {
     if (tab === 'general') {
       searchParams.delete('tab');
       searchParams.delete('tab');
     } else {
     } else {
@@ -204,6 +214,17 @@ export function SettingsPage() {
     }
     }
     setSearchParams(searchParams, { replace: true });
     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 [showCreateAPIKey, setShowCreateAPIKey] = useState(false);
   const [newAPIKeyName, setNewAPIKeyName] = useState('');
   const [newAPIKeyName, setNewAPIKeyName] = useState('');
   const [newAPIKeyPermissions, setNewAPIKeyPermissions] = useState({
   const [newAPIKeyPermissions, setNewAPIKeyPermissions] = useState({
@@ -4070,8 +4091,37 @@ export function SettingsPage() {
       )}
       )}
 
 
       {/* Filament Tab */}
       {/* 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">
         <div className="flex flex-col lg:flex-row gap-4 lg:gap-6">
           {/* Left Column */}
           {/* Left Column */}
           <div className="lg:w-1/2 space-y-3">
           <div className="lg:w-1/2 space-y-3">
@@ -4798,6 +4848,8 @@ export function SettingsPage() {
           </Card>
           </Card>
           </div>
           </div>
         </div>
         </div>
+          )}
+        </div>
       )}
       )}
 
 
       {activeTab === 'filament' && localSettings && (
       {activeTab === 'filament' && localSettings && (

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 1
static/assets/index-Bxs6ZGEZ.css


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 1 - 0
static/assets/index-CcI7-4jx.css


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/index-D51oc-O3.js


+ 2 - 2
static/index.html

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

Algúns arquivos non se mostraron porque demasiados arquivos cambiaron neste cambio