From d6bdb7e20057f0d21f756274910ad602d4ade02f Mon Sep 17 00:00:00 2001 From: maziggy Date: Sat, 27 Jun 2026 13:56:18 +0200 Subject: [PATCH] =?UTF-8?q?feat(slicer):=20Slicer=20Pipelines=20=E2=80=94?= =?UTF-8?q?=20save=20&=20reuse=20a=20preset=20bundle=20in=20one=20click=20?= =?UTF-8?q?(#1425=20PR=20A)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- CHANGELOG.md | 2 + backend/app/api/routes/slicer_pipelines.py | 177 ++++++++++ backend/app/core/auth.py | 7 + backend/app/core/database.py | 33 ++ backend/app/core/permissions.py | 16 + backend/app/main.py | 2 + backend/app/models/__init__.py | 2 + backend/app/models/slicer_pipeline.py | 61 ++++ backend/app/schemas/slicer_pipeline.py | 72 ++++ .../integration/test_slicer_pipelines_api.py | 158 +++++++++ frontend/scripts/check-i18n-parity.mjs | 6 + .../__tests__/components/SliceModal.test.tsx | 159 ++++++++- .../src/__tests__/pages/SettingsPage.test.tsx | 52 +++ frontend/src/api/client.ts | 52 +++ frontend/src/components/SliceModal.tsx | 145 ++++++++ .../src/components/SlicerPipelinesPanel.tsx | 315 +++++++++++++++++ frontend/src/i18n/locales/de.ts | 56 ++++ frontend/src/i18n/locales/en.ts | 55 +++ frontend/src/i18n/locales/es.ts | 56 ++++ frontend/src/i18n/locales/fr.ts | 56 ++++ frontend/src/i18n/locales/it.ts | 56 ++++ frontend/src/i18n/locales/ja.ts | 56 ++++ frontend/src/i18n/locales/ko.ts | 60 +++- frontend/src/i18n/locales/pt-BR.ts | 56 ++++ frontend/src/i18n/locales/tr.ts | 56 ++++ frontend/src/i18n/locales/zh-CN.ts | 56 ++++ frontend/src/i18n/locales/zh-TW.ts | 56 ++++ frontend/src/pages/SettingsPage.tsx | 58 +++- static/assets/index-Bxs6ZGEZ.css | 2 - static/assets/index-CcI7-4jx.css | 2 + .../{index-B1_XGLrL.js => index-D51oc-O3.js} | 316 +++++++++--------- static/index.html | 4 +- 32 files changed, 2084 insertions(+), 176 deletions(-) create mode 100644 backend/app/api/routes/slicer_pipelines.py create mode 100644 backend/app/models/slicer_pipeline.py create mode 100644 backend/app/schemas/slicer_pipeline.py create mode 100644 backend/tests/integration/test_slicer_pipelines_api.py create mode 100644 frontend/src/components/SlicerPipelinesPanel.tsx delete mode 100644 static/assets/index-Bxs6ZGEZ.css create mode 100644 static/assets/index-CcI7-4jx.css rename static/assets/{index-B1_XGLrL.js => index-D51oc-O3.js} (66%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a768ab30..fbbd672ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,8 @@ All notable changes to Bambuddy will be documented in this file. - **HMS Action buttons now reach the printer (#1830, H2D/H2C wrong-plate verification)** — The HMS Actions feature shipped in #1743 looked correct at the publish layer but the firmware silently dropped the commands at the printer, so clicking "Stop printing", "Problem solved and resume", or "Ignore and resume" did nothing visible on the live H2D — the modal kept reappearing, the print stayed paused, and the route still returned `200 OK`. Three independent bugs combined into one user-facing failure. **(1) Wrong command shape for resume / stop.** `hms_resume()` and `hms_stop()` sent the documented-but-not-actually-used `{"err": , "param": "reserve", "job_id": , ...}` shape that BambuStudio never produces. Bambu firmware rejects this silently — verified by injecting candidate shapes on `device//request` against a live H2D paused on a wrong-plate HMS: the `err`-bearing shape held PAUSE → PAUSE for the full window, the plain `{"print":{"command":"stop","param":"","sequence_id":"0"}}` transitioned PAUSE → FAILED in 1.7s, the same plain `resume` transitioned PAUSE → RUNNING in <2s. Fix: both helpers send the plain shape now, no `err`, no `job_id`, no `param:"reserve"`. **(2) `IGNORE_RESUME` mapped to the wrong command for paused prints.** The original mapping dispatched `idle_ignore` for both `IGNORE_RESUME` and `NO_REMINDER_NEXT_TIME`. `idle_ignore` is BambuStudio's "dismiss this warning" command and only works for non-pause warnings — verified against the H2D, idle_ignore on a paused print is silently rejected regardless of `err`. `hms_ignore()` now branches on `self.state.gcode_state == "PAUSE"`: paused → dispatch plain `resume` (which is what the button actually means on a paused print), running/idle → keep `idle_ignore` with the `type=0/1` persistence flag. `DONT_REMIND_NEXT_TIME` on PAUSE degrades to resume too — the "don't remind" flag can't ride along on a resume but the user's clicked-action intent (continue printing) is honoured. **(3) 64-bit `hms[]`-array faults truncated to a non-matching `err` (#1830 §(1)).** The hms[] parser at line 2740 built the short code as `f"{(attr >> 16) & 0xFFFF:04X}_{code & 0xFFFF:04X}"`, discarding 32 of the 64 bits of the fault identifier. For codes whose full form is e.g. `0C00_0300_0002_000C`, the truncated `0C00000C` doesn't match what the firmware compares against in `idle_ignore`. New `HMSError.full_code` field carries the canonical hex identifier — 16 chars `f"{attr:08X}{code:08X}"` for hms[]-sourced faults, 8 chars `f"{print_error:08X}"` for print_error-sourced faults (which are already 32-bit). Catalog lookup tries the 16-char form first and falls back to the 8-char short code so existing entries keep matching. Frontend echoes `error.full_code` back as `HmsActionBody.print_error` instead of recomputing the short code; the schema's pattern relaxes to `^[0-9A-Fa-f]{8}([0-9A-Fa-f]{8})?$` to accept both lengths. **(4) Masking failure — publish-success returned as printer-ack (#1830 §(3)).** `execute_hms_action` returned True the moment the publish succeeded, so any of the three bugs above produced `200 OK` while the printer ignored the command and the modal kept popping. The `/hms/execute-action` route now snapshots `(gcode_state, print_error, hms_errors count)` before dispatch, awaits `HMS_ACTION_ACK_WAIT_SECONDS` (default 2.5s, module-level so tests override), and returns `502 "Printer did not acknowledge HMS action within 2.5s"` if none of those moved. Every accepted HMS action mutates at least one of the three, so this is a clean signal. **Empirical verification.** A test harness on `device/0948BB540200427/request` confirmed each shape against the live H2D: a print sent with deliberately-wrong build plate raises `print_error=0x05008051` ("Detected build plate is not the same as the Gcode file"), the printer enters `gcode_state=PAUSE`, and the new command shapes transition out correctly. The current Bambuddy code (before this fix) failed to act on every button. **Tests.** `test_hms_actions.py` shape assertions rewritten — `test_resume_is_plain_no_err_no_job_id`, `test_stop_is_plain_no_err_no_job_id`, `test_ignore_resume_dispatches_resume_when_print_paused`, `test_ignore_resume_uses_idle_ignore_when_not_paused`, `test_dont_remind_dispatches_resume_when_paused`, `test_dont_remind_uses_idle_ignore_type_one_when_not_paused`, `test_idle_ignore_accepts_16_char_full_code`. New `TestHMSFullCode` class in `test_bambu_mqtt.py` pins the parser contract — `test_hms_array_path_populates_16_char_full_code`, `test_print_error_path_populates_8_char_full_code`, `test_hms_array_catalog_lookup_tries_16_char_first`, `test_hms_array_catalog_falls_back_to_8_char`. New integration cases in `test_printers_api.py` — `test_execute_hms_action_no_printer_ack_returns_502`, `test_execute_hms_action_accepts_16_char_full_code`. The malformed-input test now covers 9- and 15-char rejections (the relaxed pattern accepts 8 OR 16, nothing in between). `pytest -n 30 backend/tests/unit/services/test_hms_actions.py backend/tests/unit/services/test_bambu_mqtt.py backend/tests/unit/services/test_printer_manager.py backend/tests/integration/test_printers_api.py` green (509 + 181). `ruff check` clean. Frontend `npm run build` clean. **Scope.** No DB migration. No new permission. No new i18n key — the frontend toast on action failure already uses the existing `hmsErrors.actionFailed` string, which now gets the more accurate "Printer did not acknowledge" message instead of "Failed to send action". The `HMSError.full_code` field defaults to `""` so old in-memory state surviving a backend upgrade (without an MQTT reconnect) degrades to the existing 8-char short code via the frontend's `||` fallback. ### Added +- **Slicer Pipelines — save & reuse a preset bundle in one click (#1425 PR A, requested by @TheUltimateC0der)** — Top feature in the first sponsor vote. 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 — operators want a named "Production PLA" bundle they can apply with one click on every file and every printer. **PR A scope.** Definitions only. The new model `slicer_pipelines` materialises the bundle plus future-PR columns (`target_kind`, `target_printer_id`, `target_model_class`, `fanout_strategy`) so PR B (single-target dispatch) and PR C (multi-copy batch with capability-matched fanout) are code-only, not migrations. The bundle is independently useful in PR A as an ergonomic improvement: pipelines are picked from the SliceModal, applied to the four slots, then sliced through the existing flow. No new dispatch behaviour yet. **Backend.** Model `SlicerPipeline` (`models/slicer_pipeline.py`), Pydantic schemas `SlicerPipelineCreate` / `Update` / `Response` reusing the existing `PresetRef` shape from `schemas/slicer.py`, CRUD routes at `/api/v1/slicer-pipelines/` (`GET list`, `POST create`, `GET/PUT/DELETE by id`). Soft-delete via `is_deleted` so PR B+ run history can still resolve pipeline metadata after the operator removes one. Listed newest-first by `id DESC` (more reliable than `created_at` under back-to-back inserts whose DateTime precision can tie). Routes use explicit `await db.commit()` after the mutation (matches the `routes/library.py` pattern) so the response shape returns the committed row. **Permissions.** Three new `Permission` values: `PIPELINES_READ`, `PIPELINES_WRITE`, `PIPELINES_RUN`. PR A only consumes the first two; `RUN` is defined now so PR C doesn't need to backfill. `Administrators` and `Operators` get all three; `Viewers` get `PIPELINES_READ`. A backfill block in `seed_default_groups()` adds them to existing groups on upgrade (mirrors the `library:purge` / `archives:purge` pattern from earlier). All three are added to `_APIKEY_DENIED_PERMISSIONS` so they fail closed for any API-key surface — PR B / PR C may move `PIPELINES_RUN` onto `can_queue` once the dispatch lands. **Frontend.** Settings → Workflow tab is split into two sub-tabs mirroring the Authentication tab's pattern: **Queue & Dispatch** (the existing Workflow content) and **Pipelines** (the new manager). The Workflow sidebar entry stays single — no expandable submenu — and the sub-tab choice is reflected in the URL (`?tab=queue&sub=pipelines`) for deep-linking. **SlicerPipelinesPanel** lists saved pipelines with inline rename, soft-delete, and a stale-preset warning when a referenced preset no longer resolves against the unified-presets listing (e.g. an `orca_cloud` preset deleted in OrcaSlicer; the pipeline still saves, the warning prompts a re-save from the SliceModal). Full pipeline creation lives in the **SliceModal** rather than Settings — the user has already done the four-slot work there. The modal grows an `Apply pipeline ▾` dropdown plus a `Save as pipeline` button above the existing preset dropdowns. Apply fills all four slot states (`printerPreset`, `processPreset`, `bedType`, `filamentPresets[]`); 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 (lets the same pipeline apply across single-color and multi-color files). Save captures the four-slot picks under an inline-named pipeline. Stale-preset warning shows on the Settings list, not blocking apply, so an old pipeline with a one-deleted-preset can still be re-applied and re-saved with the new pick. **i18n.** ~30 new keys across `settings.pipelines.*` and `slice.pipelines.*` plus `settings.tabs.queueDispatch` / `queuePipelines`, translated in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW). Parity check 5437 leaves per locale, no English fallback. `Pipeline` / `Pipelines` / `Filament {{n}}` added to `IDENTICAL_TO_EN_ALLOWED` for the locales where they're genuine cognates (de / es / fr / it / pt-BR / tr). **Tests.** Backend: 11 integration cases in `test_slicer_pipelines_api.py` covering empty list, create + round-trip, get-by-id, partial PUT preserves untouched fields, filament list replaces wholesale, soft-delete hides from list + GET-by-id, 404s on missing, schema rejection of empty filament list + invalid PresetRef source, newest-first ordering. Frontend: 3 new SliceModal cases (apply-pipeline dropdown disabled-empty / apply-sets-state / save-as-pipeline-round-trip) plus 2 SettingsPage cases (sub-tab nav renders + Pipelines deep-link). Existing SliceModal tests adjusted via a `presetSelects()` helper that filters out the new Apply-pipeline combobox so historical `selects[0]` indexing into printer/process/filament remains stable. **Suites.** `pytest -n 30 backend/tests/` 6517/6517 green; `npx vitest run` 2274/2274 green (171 files); `npm run build` clean; `python -m ruff check backend/` clean; `node scripts/check-i18n-parity.mjs` clean. **Scope.** No new dispatch behaviour yet — pipelines are a preset-bundle convenience layer in PR A. PR B adds single-target dispatch (the `target_kind='specific_printer'` path), PR C adds multi-copy batch with capability matching + the three fanout strategies (`max_parallel` / `fill_one_first` / `round_robin`). The `Run pipeline` action mentioned in the original issue is PR B/C and intentionally not exposed in this drop. Painted multi-filament 3MFs still hit the upstream OrcaSlicer CLI gate (`OrcaSlicer/OrcaSlicer#13774`); the slice fails, the pipeline doesn't pre-validate. + - **Sticky upload-progress toast restored for scheduler-driven dispatch (#1625 follow-up)** — `#1625` (`Unify print dispatch through the scheduler`) moved every print's FTP push to the printer into the server-side scheduler tick, which means the user's click no longer carries an XHR with `progress` events — the old browser-side upload modal had nothing to show because there was no browser-side upload anymore. Users only saw the queue item flip to "active" with no visibility into the multi-second to multi-minute FTP push + the H2D/H2D Pro 80–210 s `project_file` digestion window before the printer actually started extruding. **Fix.** The legacy bg-dispatch toast rendering from `0b43ac0d:frontend/src/contexts/ToastContext.tsx` lines 510–650 is **ported back in place verbatim** — same DOM tree, same Tailwind classes, same `formatFileSize` bytes line, same uppercase status chip, same collapse chevron, same `awaitingPrinter` derivation, same auto-dismiss-when-all-terminal — only adapted to read from the four scheduler-side WS events introduced here instead of the legacy `background-dispatch` aggregate event. **Materialization only on actual upload start.** The toast appears when the FTP push to the printer starts (`queue_item_uploading`), NOT on `POST /queue` — a draft that emitted at queue-add time made the toast jump to "Dispatched" before any upload had happened. Four backend lifecycle WS events drive the rendering: `queue_item_uploading` (start of FTP, carries `printer_name` + `total_bytes` from `file_path.stat().st_size`), `queue_item_upload_progress` (throttled byte-level updates — first call always emits + emit when ≥200 ms elapsed OR ≥256 KB transferred since last emit, plus always emit at `bytes_transferred >= total_bytes`; this matches the legacy `background_dispatch.py:614-615` gates 1:1 so the bar feels identical on small AND large files; a single shared `_UploadProgressBridge` instance bridges from the FTP executor thread back to the asyncio loop via `run_coroutine_threadsafe`), `queue_item_acked` (watchdog confirmed printer transitioned out of `pre_state`), `queue_item_failed` (any error, with a `reason` key the toast looks up as `dispatchToast.failed.{reason}` for upload-vs-start-command differentiation, generic fallback). **No `queue_item_dispatched` event** — the legacy bg-dispatch path kept `status='processing'` from upload start until printer ack, and the "Awaiting printer…" subtitle is derived purely from `upload_progress_pct >= 99.9` (the legacy `uploadDoneAwaitingPrinter` trick at line 568-572). An explicit `dispatched` event would push the status chip out of `PROCESSING` prematurely — which is exactly what the first screenshot-iteration showed. **Per-user routing.** New `ws_manager.broadcast_to_user(user_id, msg)` filters connections by `websocket.state.bambuddy_principal_user_id` — resolved once at WS connect time via a `select(User.id).where(User.username == principal)` lookup so per-message routing is O(connections) not O(connections × DB). Auth-disabled installs route `user_id=None` to all connections, matching the legacy single-user toast behaviour. The watchdog success path receives `created_by_id` via a new kwarg so the static `_watchdog_print_start` method can still emit the `acked` event without re-fetching the queue item. **Backend.** ~110 LOC across 3 files: `core/websocket.py` (`broadcast_to_user` + four event helpers, `bambuddy_principal_user_id` filter on each connection), `api/routes/websocket.py` (principal username → User.id resolve at connect, stashed on `websocket.state.bambuddy_principal_user_id`), `services/print_scheduler.py` (`_UploadProgressBridge` thread-safe throttle class, `queue_item_uploading` emitted before FTP with `printer.name`, `progress_callback=` plumbed into both the `with_ftp_retry` and direct `upload_file_async` branches via `**kwargs`, `queue_item_failed` at the FTP-fail spot, watchdog success path emits `acked` on both Phase A and Phase B exits). **Frontend.** Rendering ported in place to `contexts/ToastContext.tsx` (`dispatchData` field on `Toast`, ingest `useEffect` mapping the four `bambuddy:dispatch-toast` event types to legacy `DispatchToastJob` shape, terminal-state auto-dismiss `useEffect`; legacy rendering block reused 1:1 minus the cancel button — BG dispatch's `/background-dispatch/{id}` DELETE doesn't exist in the scheduler model and adding it is out of scope). `hooks/useWebSocket.ts` forwards the four `queue_item_*` cases via `window.dispatchEvent(new CustomEvent('bambuddy:dispatch-toast', { detail }))`, matching the existing `plate-not-empty` / `unknown-tag` patterns. **i18n.** 11 keys × 11 locales under `dispatchToast` (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW): `untitled` / `startingPrints` / `progressSummary` (header `{{complete}}/{{total}} complete • Processing: {{processing}}` — `Dispatched: X` from the legacy summary was dropped because the scheduler has no pre-upload "dispatched" state) / `expandDetails` / `collapseDetails` / `awaitingPrinter` / `status.{processing|completed|failed}` / `failed.{generic|upload_failed|start_command_failed}` / `dismiss`. Locale parity check 5401 leaves per locale, no English fallback. **Tests.** Backend `test_ws_broadcast_to_user.py` pins the routing contract (filter by user_id, fan-out on None, payload shape with `printer_name` for `uploading`, server-side pct compute including divide-by-zero); `test_upload_progress_bridge.py` pins the throttle (first call always emits, 256 KB byte gate honoured even when time gate would skip, completion always emits, no-op on zero bytes, no-op when no loop). Frontend `__tests__/contexts/DispatchToastContext.test.tsx` pins the **materialization-on-uploading invariant** (stray progress / acked event before any `uploading` does NOT render — regression guard), the uploading → "Awaiting printer…" → acked lifecycle with status chip staying `PROCESSING` through the whole upload (regression guard for the screenshot-reported "Dispatched: 1 immediately" bug), 3.5 s auto-dismiss when terminal, concurrent jobs sharing one wrapper, collapse + dismiss buttons. **Suites.** `pytest -n 30 backend/tests/unit/test_ws_broadcast_to_user.py backend/tests/unit/test_upload_progress_bridge.py backend/tests/integration/test_print_queue_api.py` green; `vitest run src/__tests__/contexts/` 49/49 green; `ruff check backend/` clean; `npm run build` clean. **Scope.** No DB migration. No new permission. The `bambuddy:dispatch-toast` window event is internal to the frontend bundle, not a public hook — third-party plugins should not subscribe to it. The 0–30 s scheduler-tick pickup wait is unchanged; this fix only addresses *visibility* of what happens once the upload starts. Tiny test files that upload in a single FTP chunk will still jump straight to "Awaiting printer…" because the first-and-last progress callback is one and the same event — same edge as the legacy bg-dispatch behaviour on sub-256 KB files. - **Sponsor-prompt thresholds lowered to fire for typical new installs** — The in-app sponsor toast in `useSponsorPrompt` was calibrated for power users: the lowest print milestone was `100`, the lowest archive milestone was `50`, the lowest filament-cost milestone was `100`. A check of recent Matomo data showed the toast firing very rarely (`?from=app-toast-prints-100` = 4 visits, `?from=app-toast-archives-50` = 3 visits in a 7-day window) — most installs simply never reach those bars, especially with the install base ~doubling since March. Calibration widened: `PRINT_MILESTONES` now `(10, 25, 100, 500, 1000, 2500, 5000)`, `ARCHIVE_MILESTONES` now `(5, 10, 50, 250, 1000)`, `COST_MILESTONES` now `(25, 50, 100, 500, 1000)`. The existing priority order (anniversary → prints → archives → cost → version-update) and 14-day cross-family cooldown are unchanged, so a user still sees at most one toast per fortnight. The "fire highest unseen milestone" logic in `_check_prints` / `_check_archives` / `_check_cost` is unchanged — a user already at 200 prints still gets `prints-100` first (they crossed it earlier in the timeline). The existing toast copy uses `{count}` / `{total}` interpolation in all 11 locales — no new i18n keys needed; "You've completed 10 prints with Bambuddy" reads as fluently as the 100 variant. **Tests.** `test_failed_prints_dont_count` and `test_fires_when_cost_sum_crosses_100` rebalanced (5 completed prints instead of 50; 5 prints × 21 cost-each instead of 30 × 3.5) so they still test "below the lowest threshold" semantics with the new lower bars. New `test_fires_at_lowest_threshold` pins `prints-10` as the new minimum trigger. `pytest -n 30 backend/tests/unit/test_sponsor_prompt_service.py backend/tests/integration/test_sponsor_prompt_api.py` green (25/25). `ruff check` clean. **Scope.** No DB migration. No new permission. No frontend change. The change is opt-in by virtue of the existing toast cooldown — installs that already saw a recent toast see no behaviour change; installs that never crossed the old 100-print bar become eligible the first time they pass 10 prints (subject to the 14-day cooldown after any other family fires first). diff --git a/backend/app/api/routes/slicer_pipelines.py b/backend/app/api/routes/slicer_pipelines.py new file mode 100644 index 000000000..16b3e1d69 --- /dev/null +++ b/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() diff --git a/backend/app/core/auth.py b/backend/app/core/auth.py index b54e49e3b..eab857480 100644 --- a/backend/app/core/auth.py +++ b/backend/app/core/auth.py @@ -222,6 +222,13 @@ _APIKEY_DENIED_PERMISSIONS: frozenset[Permission] = frozenset( Permission.SMART_PLUGS_DELETE, # Network scanning — operator only (no API-key scope for this). Permission.DISCOVERY_SCAN, + # Slicer Pipelines (#1425) — admin authoring + the print-spending Run + # action. PR A only ships CRUD; PR B / PR C may move PIPELINES_RUN onto + # `can_queue` (it queues prints) once the run dispatch lands. PR A keeps + # all three denied so they fail closed for any API-key surface. + Permission.PIPELINES_READ, + Permission.PIPELINES_WRITE, + Permission.PIPELINES_RUN, } ) diff --git a/backend/app/core/database.py b/backend/app/core/database.py index c922e75ca..860508396 100644 --- a/backend/app/core/database.py +++ b/backend/app/core/database.py @@ -198,6 +198,7 @@ async def init_db(): project_bom, settings, shopping_list, + slicer_pipeline, slot_preset, smart_plug, smart_plug_energy_snapshot, @@ -3594,6 +3595,38 @@ async def seed_default_groups(): group.permissions = perms await session.commit() + # Backfill pipeline permissions (#1425). Pipelines were added after + # initial seeding, so existing groups need them appended: + # - Administrators: all three (matches fresh-install ALL_PERMISSIONS) + # - Operators: all three (matches fresh-install DEFAULT_GROUPS) + # - Viewers + any group with library:read_own or settings:read: + # pipelines:read only + result = await session.execute(select(Group)) + for group in result.scalars().all(): + if not group.permissions: + continue + perms = list(group.permissions) + changed = False + if group.name == "Administrators": + for new_perm in ("pipelines:read", "pipelines:write", "pipelines:run"): + if new_perm not in perms: + perms.append(new_perm) + changed = True + logger.info("Added %s to Administrators group (backfill)", new_perm) + elif group.name == "Operators": + for new_perm in ("pipelines:read", "pipelines:write", "pipelines:run"): + if new_perm not in perms: + perms.append(new_perm) + changed = True + logger.info("Added %s to Operators group (backfill)", new_perm) + elif "pipelines:read" not in perms and ("library:read_own" in perms or "settings:read" in perms): + perms.append("pipelines:read") + changed = True + logger.info("Added pipelines:read to group '%s' (backfill)", group.name) + if changed: + group.permissions = perms + await session.commit() + # Migrate existing users to groups if they're not already in any group if groups_created: # Refresh to get newly created groups diff --git a/backend/app/core/permissions.py b/backend/app/core/permissions.py index acc574c53..d87ed9fad 100644 --- a/backend/app/core/permissions.py +++ b/backend/app/core/permissions.py @@ -178,6 +178,11 @@ class Permission(StrEnum): GROUPS_UPDATE = "groups:update" GROUPS_DELETE = "groups:delete" + # Slicer Pipelines (#1425) + PIPELINES_READ = "pipelines:read" # View pipeline definitions and run history + PIPELINES_WRITE = "pipelines:write" # Create / edit / delete pipeline definitions + PIPELINES_RUN = "pipelines:run" # Kick off a pipeline run (PR C); separate because spending filament is a different trust dimension than authoring the recipe + # WebSocket connection WEBSOCKET_CONNECT = "websocket:connect" @@ -337,6 +342,11 @@ PERMISSION_CATEGORIES = { Permission.GROUPS_UPDATE, Permission.GROUPS_DELETE, ], + "Slicer Pipelines": [ + Permission.PIPELINES_READ, + Permission.PIPELINES_WRITE, + Permission.PIPELINES_RUN, + ], "WebSocket": [ Permission.WEBSOCKET_CONNECT, ], @@ -452,6 +462,10 @@ DEFAULT_GROUPS = { Permission.SYSTEM_READ.value, # Settings - read only Permission.SETTINGS_READ.value, + # Slicer Pipelines - full access + Permission.PIPELINES_READ.value, + Permission.PIPELINES_WRITE.value, + Permission.PIPELINES_RUN.value, # WebSocket Permission.WEBSOCKET_CONNECT.value, ], @@ -483,6 +497,8 @@ DEFAULT_GROUPS = { Permission.STATS_READ.value, Permission.SYSTEM_READ.value, Permission.SETTINGS_READ.value, + # Slicer Pipelines - read only + Permission.PIPELINES_READ.value, Permission.WEBSOCKET_CONNECT.value, # MakerWorld browsing only (no import — that writes to library) Permission.MAKERWORLD_VIEW.value, diff --git a/backend/app/main.py b/backend/app/main.py index 5a9c89495..37bcb5120 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -56,6 +56,7 @@ from backend.app.api.routes import ( projects, settings as settings_routes, slice_jobs, + slicer_pipelines, slicer_presets, smart_plugs, sponsor_prompt, @@ -6750,6 +6751,7 @@ app.include_router(library.router, prefix=app_settings.api_prefix) app.include_router(library_tags.router, prefix=app_settings.api_prefix) app.include_router(library_trash.router, prefix=app_settings.api_prefix) app.include_router(slice_jobs.router, prefix=app_settings.api_prefix) +app.include_router(slicer_pipelines.router, prefix=app_settings.api_prefix) app.include_router(slicer_presets.router, prefix=app_settings.api_prefix) app.include_router(archive_purge.router, prefix=app_settings.api_prefix) app.include_router(makerworld.router, prefix=app_settings.api_prefix) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 82accea74..46ee763f6 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -23,6 +23,7 @@ from backend.app.models.printer import Printer from backend.app.models.printer_sensor_history import PrinterSensorHistory from backend.app.models.project import Project from backend.app.models.settings import Settings +from backend.app.models.slicer_pipeline import SlicerPipeline from backend.app.models.smart_plug import SmartPlug from backend.app.models.smart_plug_energy_snapshot import SmartPlugEnergySnapshot from backend.app.models.sponsor_toast_state import SponsorToastState @@ -69,6 +70,7 @@ __all__ = [ "OIDCProvider", "UserOIDCLink", "OrcaBaseProfile", + "SlicerPipeline", "Spool", "SpoolKProfile", "SpoolAssignment", diff --git a/backend/app/models/slicer_pipeline.py b/backend/app/models/slicer_pipeline.py new file mode 100644 index 000000000..429793a13 --- /dev/null +++ b/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()) diff --git a/backend/app/schemas/slicer_pipeline.py b/backend/app/schemas/slicer_pipeline.py new file mode 100644 index 000000000..ca876c03d --- /dev/null +++ b/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] = [] diff --git a/backend/tests/integration/test_slicer_pipelines_api.py b/backend/tests/integration/test_slicer_pipelines_api.py new file mode 100644 index 000000000..d96890367 --- /dev/null +++ b/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"] diff --git a/frontend/scripts/check-i18n-parity.mjs b/frontend/scripts/check-i18n-parity.mjs index f68ad89ab..8446103f5 100644 --- a/frontend/scripts/check-i18n-parity.mjs +++ b/frontend/scripts/check-i18n-parity.mjs @@ -147,6 +147,7 @@ const DE_COGNATES = [ 'Pause', 'Power', 'System', 'Problem', 'Designer', 'Extruder', 'Firmware', 'Material', 'Original', 'Position', 'Webhook', 'Workflow', 'Slicer', + 'Pipeline', 'Pipelines', 'Filament {{n}}', // #1425 — Slicer Pipelines (DE) 'Region', 'Normal', 'Orange', 'Branch', 'Budget', 'Commit', 'Global', 'Version', 'Slot', 'Live', 'Rate', 'Host', 'Trend', 'Min', 'Admin', 'Cloud', 'Filament', 'Filaments', 'Software', 'Hardware', 'Avatar', 'Pin', 'Modal', @@ -179,6 +180,7 @@ const FR_COGNATES = [ 'Filaments', 'Software', 'Hardware', 'Stop', 'Reset', 'Test', 'Code', 'Token', 'Server', 'Port', 'Plate', 'Layer', 'Active', 'Total', 'Avatar', 'Job', 'Modal', 'Pin', 'Pro', 'Mini', 'Studio', 'Excellent', 'Description', + 'Pipeline', 'Pipelines', 'Filament {{n}}', // #1425 — Slicer Pipelines (FR) 'Action', 'Actions', 'Date', 'Type', 'Cache', 'Service', 'Configuration', 'Archives', 'Maintenance', 'Notifications', 'Notification', 'Position', 'Pause', 'Solution', 'Source', 'Version', 'Format', 'Documentation', @@ -217,6 +219,7 @@ const IT_COGNATES = [ 'Bambu Cloud', 'Orca Cloud', // brand names — same in every locale 'AMS Filament Backup', // Bambu Lab product/firmware feature name 'Email', // common loanword in Italian, used verbatim in UI labels + 'Pipeline', // #1425 — Slicer Pipelines (cognate in IT) 'Status', 'Tag', 'Tags', 'Online', 'Offline', 'Standard', 'Filament', 'Filaments', 'Software', 'Hardware', 'Stop', 'Reset', 'Test', 'Code', 'Token', 'Server', 'Port', 'Plate', 'Layer', 'Modal', 'Pin', 'Pro', 'Mini', @@ -260,6 +263,7 @@ const JA_COGNATES = [ const PT_BR_COGNATES = [ 'Bambu Cloud', 'Orca Cloud', // brand names — same in every locale 'AMS Filament Backup', // Bambu Lab product/firmware feature name + 'Pipeline', 'Pipelines', // #1425 — Slicer Pipelines (PT-BR) 'Status', 'Tag', 'Tags', 'Online', 'Offline', 'Standard', 'Filament', 'Software', 'Hardware', 'Stop', 'Reset', 'Test', 'Code', 'Token', 'Server', 'Port', 'Plate', 'Layer', 'Modal', 'Pin', 'Pro', 'Mini', 'Studio', 'Cache', @@ -332,6 +336,7 @@ const KO_COGNATES = [ const ES_COGNATES = [ 'Bambu Cloud', 'Orca Cloud', // brand names — same in every locale 'AMS Filament Backup', // Bambu Lab product/firmware feature name + 'Pipeline', 'Pipelines', // #1425 — Slicer Pipelines (ES) 'Error', 'Firmware', 'General', 'Control', 'Total', 'total', 'Material', 'Material:', 'Color', 'Hex', 'Local', 'Global', 'China', 'Editable', 'Normal', 'Metal', 'Multicolor', 'Proxy', 'Host', 'Factor', 'Original', @@ -353,6 +358,7 @@ const TR_COGNATES = [ 'Filament', 'Firmware', 'Disk', 'Hex', 'Test', 'Port', 'Model', 'Metal', 'Bambu Cloud', 'Orca Cloud', // brand names — same in every locale 'AMS Filament Backup', // Bambu Lab product/firmware feature name + 'Pipeline', 'Filament {{n}}', // #1425 — Slicer Pipelines (TR) 'Min', 'Normal', 'Platform', 'Net', 'Trend', 'Commit', 'Global', 'Proxy', 'N/A', 'email', 'STARTTLS (Port 587)', 'SSL/TLS (Port 465)', diff --git a/frontend/src/__tests__/components/SliceModal.test.tsx b/frontend/src/__tests__/components/SliceModal.test.tsx index 107999bde..d1c8a1256 100644 --- a/frontend/src/__tests__/components/SliceModal.test.tsx +++ b/frontend/src/__tests__/components/SliceModal.test.tsx @@ -28,6 +28,9 @@ vi.mock('../../api/client', () => ({ getArchiveFilamentRequirements: vi.fn(), getSettings: vi.fn().mockResolvedValue({}), updateSettings: vi.fn().mockResolvedValue({}), + // Slicer Pipelines (#1425) + listSlicerPipelines: vi.fn(), + createSlicerPipeline: vi.fn(), }, })); @@ -40,6 +43,8 @@ const mockApi = api as unknown as { getArchivePlates: ReturnType; getLibraryFileFilamentRequirements: ReturnType; getArchiveFilamentRequirements: ReturnType; + listSlicerPipelines: ReturnType; + createSlicerPipeline: ReturnType; }; function makeUnified(overrides: Partial = {}): UnifiedPresetsResponse { @@ -80,6 +85,16 @@ function renderWithTracker(props: Parameters[0]) { ); } +// SliceModal renders one extra combobox for the Slicer Pipelines (#1425) +// "Apply pipeline" dropdown above the preset slots. Tests written before +// pipelines landed assume selects[0] = printer; this helper drops the +// pipeline combobox so those indices stay stable. +function presetSelects(): HTMLSelectElement[] { + return (screen.getAllByRole('combobox') as HTMLSelectElement[]).filter( + (el) => el.getAttribute('aria-label') !== 'Apply pipeline', + ); +} + describe('SliceModal', () => { beforeEach(() => { vi.clearAllMocks(); @@ -121,6 +136,8 @@ describe('SliceModal', () => { plate_id: 1, filaments: [], }); + // Default: no saved pipelines. Tests opt in by overriding this. + mockApi.listSlicerPipelines.mockResolvedValue({ pipelines: [] }); }); it('auto-selects the highest-priority tier per slot on first load', async () => { @@ -137,7 +154,7 @@ describe('SliceModal', () => { // 4 selects: printer, process, bed-type (#1337), filament. bed-type sits // between process and filament — it overrides curr_bed_type on the // process preset so the related controls cluster — and defaults to "". - const selects = screen.getAllByRole('combobox') as HTMLSelectElement[]; + const selects = presetSelects(); expect(selects).toHaveLength(4); expect(selects[0].value).toBe('local:1'); expect(selects[1].value).toBe('local:2'); @@ -158,7 +175,7 @@ describe('SliceModal', () => { await waitFor(() => expect(screen.getByText('Imported X1C 0.4')).toBeDefined()); - const printerSelect = screen.getAllByRole('combobox')[0]; + const printerSelect = presetSelects()[0]; const groups = printerSelect.querySelectorAll('optgroup'); expect(Array.from(groups).map((g) => g.label)).toEqual([ 'Imported', @@ -190,7 +207,7 @@ describe('SliceModal', () => { }); await waitFor(() => expect(screen.getByText('Imported X1C 0.4')).toBeDefined()); - const selects = screen.getAllByRole('combobox') as HTMLSelectElement[]; + const selects = presetSelects(); expect(selects[0].value).toBe('local:1'); }); @@ -204,7 +221,7 @@ describe('SliceModal', () => { }); await waitFor(() => expect(screen.getByText('Bambu Lab X1 Carbon 0.4 nozzle')).toBeDefined()); - const selects = screen.getAllByRole('combobox') as HTMLSelectElement[]; + const selects = presetSelects(); expect(selects[0].value).toBe('standard:Bambu Lab X1 Carbon 0.4 nozzle'); }); @@ -260,7 +277,7 @@ describe('SliceModal', () => { // printer (0), process (1), bed-type (2), filament (3+). Find the // bed-type select by name rather than positional index so this stays // green if the layout adds another control around it. - const bedSelect = screen.getAllByRole('combobox').find((el) => + const bedSelect = presetSelects().find((el) => (el as HTMLSelectElement).options[0]?.textContent?.toLowerCase().includes('auto'), ) as HTMLSelectElement; expect(bedSelect).toBeDefined(); @@ -315,7 +332,7 @@ describe('SliceModal', () => { await waitFor(() => expect(screen.getByText('My Custom X1C')).toBeDefined()); const user = userEvent.setup(); - const selects = screen.getAllByRole('combobox'); + const selects = presetSelects(); await user.selectOptions(selects[0], 'standard:Bambu Lab X1 Carbon 0.4 nozzle'); await user.click(screen.getByRole('button', { name: /^Slice$/ })); @@ -731,7 +748,7 @@ describe('SliceModal', () => { await waitFor(() => expect(screen.getByText('X1C')).toBeDefined()); // 1 printer + 1 process + 2 filament + 1 bed-type (#1337) = 5 dropdowns. - expect(screen.getAllByRole('combobox')).toHaveLength(5); + expect(presetSelects()).toHaveLength(5); }); it('pre-picks each filament slot by matching colour metadata', async () => { @@ -812,7 +829,7 @@ describe('SliceModal', () => { await waitFor(() => expect(screen.getByText('X1C')).toBeDefined()); const user = userEvent.setup(); - const selects = screen.getAllByRole('combobox') as HTMLSelectElement[]; + const selects = presetSelects(); // Order: 0 printer, 1 process, 2 bed-type, 3 filament-1, 4 filament-2 // (#1337). Auto-picks land on printer/process/filaments; bed-type // defaults to "". Swap filament-1 (index 3) from the auto-picked black @@ -937,7 +954,7 @@ describe('SliceModal', () => { // Both filament rows render — 1 printer + 1 process + 1 bed-type + // 2 filament (#1337) = 5. bed-type sits at index 2, filament slots // follow at 3 and 4. - const selects = screen.getAllByRole('combobox') as HTMLSelectElement[]; + const selects = presetSelects(); expect(selects).toHaveLength(5); // Slot 1 (used) is editable, slot 2 (not used) is disabled. expect(selects[3].disabled).toBe(false); @@ -1018,4 +1035,128 @@ describe('SliceModal', () => { }); }); + // ------------------------------------------------------------------ + // Slicer Pipelines (#1425) — Apply / Save integration in SliceModal + // ------------------------------------------------------------------ + + it('Apply pipeline dropdown is disabled and shows empty hint when no pipelines exist', async () => { + mockApi.listSlicerPipelines.mockResolvedValue({ pipelines: [] }); + renderWithTracker({ + source: { kind: 'libraryFile', id: 100, filename: 'Cube.stl' }, + onClose: vi.fn(), + }); + await waitFor(() => { + const select = screen.getByLabelText(/Apply pipeline/i) as HTMLSelectElement; + expect(select.disabled).toBe(true); + expect(select.querySelector('option')?.textContent).toMatch(/No saved pipelines/i); + }); + }); + + it('applies a saved pipeline to printer, process, and bed_type slots on selection', async () => { + mockApi.listSlicerPipelines.mockResolvedValue({ + pipelines: [ + { + id: 7, + name: 'Production Batch', + description: null, + printer_preset: { source: 'local', id: '1' }, + process_preset: { source: 'local', id: '2' }, + filament_presets: [{ source: 'local', id: '3' }], + bed_type: 'Textured PEI Plate', + target_kind: 'printer_class', + target_printer_id: null, + target_model_class: null, + fanout_strategy: 'max_parallel', + created_by: null, + created_at: '2026-06-27T00:00:00Z', + updated_at: '2026-06-27T00:00:00Z', + }, + ], + }); + + renderWithTracker({ + source: { kind: 'libraryFile', id: 100, filename: 'Cube.stl' }, + onClose: vi.fn(), + }); + + // Wait for presets + pipelines listing to populate the modal. + await waitFor(() => { + const select = screen.getByLabelText(/Apply pipeline/i) as HTMLSelectElement; + expect(select.disabled).toBe(false); + expect(within(select).getByText('Production Batch')).toBeDefined(); + }); + + const user = userEvent.setup(); + await user.selectOptions(screen.getByLabelText(/Apply pipeline/i), '7'); + + // After applying, submitting the slice request should carry the + // pipeline's preset refs end-to-end. + mockApi.sliceLibraryFile.mockResolvedValue({ + job_id: 42, + status: 'queued', + status_url: '/api/v1/slice-jobs/42', + }); + + await user.click(screen.getByRole('button', { name: /^Slice$/ })); + + await waitFor(() => { + expect(mockApi.sliceLibraryFile).toHaveBeenCalled(); + const [, body] = mockApi.sliceLibraryFile.mock.calls[0]; + expect(body.printer_preset).toEqual({ source: 'local', id: '1' }); + expect(body.process_preset).toEqual({ source: 'local', id: '2' }); + expect(body.filament_presets[0]).toEqual({ source: 'local', id: '3' }); + expect(body.bed_type).toBe('Textured PEI Plate'); + }); + }); + + it('saves the current four-slot selection as a new pipeline when the user clicks Save as pipeline', async () => { + mockApi.listSlicerPipelines.mockResolvedValue({ pipelines: [] }); + mockApi.createSlicerPipeline.mockResolvedValue({ + id: 99, + name: 'My Default', + description: null, + printer_preset: { source: 'local', id: '1' }, + process_preset: { source: 'local', id: '2' }, + filament_presets: [{ source: 'local', id: '3' }], + bed_type: null, + target_kind: 'printer_class', + target_printer_id: null, + target_model_class: null, + fanout_strategy: 'max_parallel', + created_by: null, + created_at: '2026-06-27T00:00:00Z', + updated_at: '2026-06-27T00:00:00Z', + }); + + renderWithTracker({ + source: { kind: 'libraryFile', id: 100, filename: 'Cube.stl' }, + onClose: vi.fn(), + }); + + // Wait for auto-pick to populate all four slots from the fullThreeTier + // listing — then Save as pipeline becomes enabled. + const user = userEvent.setup(); + let saveBtn: HTMLButtonElement; + await waitFor(() => { + saveBtn = screen.getByRole('button', { name: /^Save as pipeline$/ }) as HTMLButtonElement; + expect(saveBtn.disabled).toBe(false); + }); + await user.click(saveBtn!); + + const nameInput = screen.getByLabelText(/New pipeline name/i); + await user.type(nameInput, 'My Default'); + await user.click(screen.getByRole('button', { name: /^Save$/ })); + + await waitFor(() => { + expect(mockApi.createSlicerPipeline).toHaveBeenCalledTimes(1); + const body = mockApi.createSlicerPipeline.mock.calls[0][0]; + expect(body.name).toBe('My Default'); + // The four slots come from the auto-picked unified-presets listing — + // local tier wins per SLICE_MODAL_TIER_ORDER. + expect(body.printer_preset.source).toBe('local'); + expect(body.process_preset.source).toBe('local'); + expect(body.filament_presets[0].source).toBe('local'); + }); + }); + }); diff --git a/frontend/src/__tests__/pages/SettingsPage.test.tsx b/frontend/src/__tests__/pages/SettingsPage.test.tsx index 9021dec78..7c4487084 100644 --- a/frontend/src/__tests__/pages/SettingsPage.test.tsx +++ b/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(); + 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(); + 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'); + }); + }); + }); }); diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 021406dbd..ad142200d 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -1502,6 +1502,39 @@ export interface UnifiedPresetsResponse { orca_cloud_status: SlicerCloudStatus; } +// Slicer Pipelines (#1425) — named bundles of preset slots the SliceModal +// can apply in one click. PR A surfaces only the bundle; target_* and +// fanout_strategy round-trip from the backend but the UI doesn't yet expose +// them (they come alive in PR B / PR C). +export interface SlicerPipeline { + id: number; + name: string; + description: string | null; + printer_preset: PresetRef; + process_preset: PresetRef; + filament_presets: PresetRef[]; + bed_type: string | null; + target_kind: 'specific_printer' | 'printer_class'; + target_printer_id: number | null; + target_model_class: string | null; + fanout_strategy: 'max_parallel' | 'fill_one_first' | 'round_robin'; + created_by: number | null; + created_at: string; + updated_at: string; +} +export interface SlicerPipelineCreateRequest { + name: string; + description?: string | null; + printer_preset: PresetRef; + process_preset: PresetRef; + filament_presets: PresetRef[]; + bed_type?: string | null; +} +export type SlicerPipelineUpdateRequest = Partial; +export interface SlicerPipelinesListResponse { + pipelines: SlicerPipeline[]; +} + export interface SliceResponse { library_file_id: number; name: string; @@ -6199,6 +6232,25 @@ export const api = { options?.refresh ? '/slicer/presets?refresh=true' : '/slicer/presets', ), + // Slicer Pipelines (#1425) — preset bundles the SliceModal can apply in + // one click. CRUD is gated on PIPELINES_READ / PIPELINES_WRITE. + listSlicerPipelines: () => + request('/slicer-pipelines/'), + getSlicerPipeline: (id: number) => + request(`/slicer-pipelines/${id}`), + createSlicerPipeline: (data: SlicerPipelineCreateRequest) => + request('/slicer-pipelines/', { + method: 'POST', + body: JSON.stringify(data), + }), + updateSlicerPipeline: (id: number, data: SlicerPipelineUpdateRequest) => + request(`/slicer-pipelines/${id}`, { + method: 'PUT', + body: JSON.stringify(data), + }), + deleteSlicerPipeline: (id: number) => + request(`/slicer-pipelines/${id}`, { method: 'DELETE' }), + // Canonical Bambu printer-model registry — "Bambu Lab " → short code. // Single source of truth shared with backend (PRINTER_MODEL_MAP); the // SliceModal uses this to classify cloud / standard presets by their diff --git a/frontend/src/components/SliceModal.tsx b/frontend/src/components/SliceModal.tsx index 2bca8e9d0..ac88c13b4 100644 --- a/frontend/src/components/SliceModal.tsx +++ b/frontend/src/components/SliceModal.tsx @@ -343,6 +343,35 @@ export function SliceModal({ source, onClose }: SliceModalProps) { // user had no way to switch plates without cloning the preset. const [bedType, setBedType] = useState(null); + // Slicer Pipelines (#1425) — apply a saved preset bundle to all four slots + // with one pick, or save the current selection as a new pipeline. + const pipelinesQuery = useQuery({ + queryKey: ['slicer-pipelines'], + queryFn: () => api.listSlicerPipelines(), + staleTime: 60_000, + }); + const [savePipelineOpen, setSavePipelineOpen] = useState(false); + const [pipelineDraftName, setPipelineDraftName] = useState(''); + const { showToast } = useToast(); + const createPipelineMutation = useMutation({ + mutationFn: (body: { + name: string; + printer_preset: PresetRef; + process_preset: PresetRef; + filament_presets: PresetRef[]; + bed_type: string | null; + }) => api.createSlicerPipeline(body), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['slicer-pipelines'] }); + showToast(t('slice.pipelines.toast.saved', 'Pipeline saved'), 'success'); + setSavePipelineOpen(false); + setPipelineDraftName(''); + }, + onError: (err: Error) => { + showToast(err.message || t('slice.pipelines.toast.saveFailed', 'Save failed'), 'error'); + }, + }); + const platesQuery = useQuery({ queryKey: ['slicePlates', source.kind, source.id], queryFn: async () => { @@ -682,6 +711,122 @@ export function SliceModal({ source, onClose }: SliceModalProps) { status === 'ok' (returns null in that case), but the Refresh button stays visible regardless so users can pick up cloud / bundled changes even when sign-in is healthy. */} + {/* Slicer Pipelines (#1425): apply a saved preset bundle to all + four slots, or save the current selection as a pipeline. + Pipelines are managed in Settings → Workflow → Pipelines. */} +
+ + {t('slice.pipelines.label', 'Pipeline')} + + + {!savePipelineOpen ? ( + + ) : ( +
+ 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" + /> + + +
+ )} +
p.id === ref.id); + return hit ? hit.name : null; +} + +const SOURCE_LABEL: Record = { + 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 ( + + +

+ + {t('settings.pipelines.title', 'Slicer Pipelines')} +

+

+ {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.', + )} +

+
+ + {isLoading && ( +
+ + {t('settings.pipelines.loading', 'Loading pipelines…')} +
+ )} + {error && ( +
+ {t('settings.pipelines.loadError', 'Could not load pipelines.')} +
+ )} + {!isLoading && !error && pipelines.length === 0 && ( +
+

{t('settings.pipelines.empty.title', 'No pipelines yet.')}

+

+ {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.', + )} +

+
+ )} + {!isLoading && !error && pipelines.length > 0 && ( +
+ {pipelines.map((p) => ( + 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} + /> + ))} +
+ )} +
+
+ ); +} + +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 ( +
+
+
+ {editing ? ( +
+ 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" + /> +