Commit Graph
821 Commits
Author SHA1 Message Date
maziggy f7c94cbc7b fix(inventory): add CF/GF to subtype dropdown on the Add/Edit Spool form (#1345)
Adding a third-party PETG-CF spool via the Material=PETG + Subtype=CF
  flow (same shape as the existing PETG HF) hit a missing option: KNOWN_VARIANTS
  in spool-form/constants.ts didn't list CF or GF. Users had to type it
  freehand into the "create new" tail of the dropdown.

  Added both: CF (matches PETG-CF / PLA-CF / ASA-CF / PA-CF) and GF (the
  natural pair for ABS-GF / PA6-GF). parsePresetName is unaffected — its
  materials list is iterated longest-first, so cloud presets like
  "Bambu PETG-CF Black" still resolve to material=PETG-CF with empty
  afterMaterial.
2026-05-15 09:47:21 +02:00
maziggy 7d7267e9cb fix(ui): z-index / stacking-context follow-ups to #1336
Two latent issues surfaced after the original AssignSpoolModal z-50 →
  z-[100] bump landed:

  1. Material-mismatch ConfirmModal hidden behind AssignSpoolModal.
     ConfirmModal's overlay was hardcoded to z-50 in its wrapper, so once
     the parent moved to z-[100] the nested confirmation dialog sat
     behind it. Added an optional overlayZIndex prop to ConfirmModal
     (defaults to z-50 — none of the 82 other call sites change), and
     the mismatch site in AssignSpoolModal passes z-[110] so the warning
     stacks above its parent.

  2. FilamentHoverCard / EmptySlotHoverCard covered by sibling printer
     cards on the dashboard. The popovers used position:absolute with
     z-[60] inside the trigger, but every printer card creates its own
     stacking context (drop-shadow filter on the slot tiles is enough),
     and z-index doesn't cross stacking-context boundaries — the next
     sibling card always wins by DOM order. Visible as the "Jade White
     · Bambu PETG HF" tooltip getting half-eaten by the neighbour card's
     AMS column.

     Fixed by portaling both hover cards to document.body with
     position:fixed and screen-space coordinates from
     triggerRef.getBoundingClientRect(). Coords recompute on visibility
     change, scroll (capture), and resize so the popover follows the
     trigger when the viewport moves; a requestAnimationFrame re-measure
     after the first paint avoids a one-frame flicker before the card
     has its rendered dimensions. Hover handlers are wired on both the
     trigger AND the portaled card so moving the cursor from slot to
     popover doesn't auto-dismiss after 100 ms. Top/bottom placement
     and arrow-pointer logic preserved.
2026-05-15 09:41:26 +02:00
maziggy 9ba1e34729 fix(archives): keep Quick Stats contribution when deleting a print (#1343)
Reported by @IndividualGhost1905: printing the same model ten times and
  then deleting nine archive entries (to keep the file list tidy) silently
  rewound the totals on the Statistics page — total prints, filament,
  cost, and per-print energy all dropped back to whatever the surviving
  row contributed, as if the other nine prints had never happened.

  Root cause: every metric in get_archive_stats is recomputed live from
  PrintArchive rows via COUNT / SUM, so removing a row removes its
  contribution. Energy in the default "Total" mode already survived
  deletion because it reads the smart-plug lifetime counters — that's
  the architectural shape we now generalise to the rest.

  Fix: soft delete with opt-in hard purge.

  Backend:
  - New nullable, indexed deleted_at column on print_archives, dialect-
    conditional migration (DATETIME on SQLite, TIMESTAMP on PostgreSQL).
  - ArchiveService.soft_delete_archive flips deleted_at and removes the
    files from disk (still reclaims storage); the path-safety checks were
    extracted into _resolve_archive_dir_for_delete so soft and hard delete
    share the rules.
  - DELETE /archives/{id} accepts ?purge_stats=true; default is soft.
  - Listings filter deleted_at IS NULL: list_archives, search FTS + LIKE
    fallback, GET /{id} (404 on soft-deleted), tag listing, duplicate
    detection (so a 1-live + 9-soft-deleted group no longer marks the
    survivor as a duplicate), and ArchiveComparisonService's "similar"
    suggestions. GET /stats and GET /slim deliberately do NOT filter so
    Quick Stats and the dashboard widgets keep counting deleted prints.

  Frontend:
  - ConfirmModal gained an optional children slot.
  - ArchivesPage (both card and detail views) own a per-instance
    deletePurgeStats boolean and render an opt-in checkbox in the delete
    dialog; resets to off on every close so the destructive option is
    never sticky.
  - api.deleteArchive(id, purgeStats?) appends ?purge_stats=true only
    when the box is ticked.
  - One new i18n key archives.modal.deletePurgeStats added across all 8
    locales (full German, English fallbacks elsewhere).
2026-05-15 09:21:43 +02:00
maziggy 7d07b92bec Post work PR #1333 2026-05-15 08:57:03 +02:00
Sn0rrii 8a7598f6b5 feat(auth): proxy OIDC provider icons server-side (#1333) (#1342)
* feat(auth): proxy OIDC provider icons server-side (#1333)

Strict img-src CSP blocked external OIDC icon hosts on the login page.
Loosening CSP was rejected via the MakerWorld precedent, so icons are
proxied: admin sets icon_url, backend fetches and caches the bytes in a
deferred BLOB column, the SPA renders from a same-origin
/api/v1/auth/oidc/providers/{id}/icon endpoint.
2026-05-15 08:50:37 +02:00
maziggy dd3e3f8039 fix(inventory): make AMS slot config land cleanly for spools with no k-profile
The assign flow was sending slicer-invalid values for tray_info_idx and an
  empty setting_id, which the slicer rejected — slot detail modal showed
  empty fields. With a stored k-profile the realignment path masked the
  issue; without one, garbage hit MQTT.

  Backend (apply_spool_to_slot_via_mqtt):
  - Discard tray_info_idx values that aren't real preset IDs: literal
    material names ("PLA", "PETG-CF") AND PFUS-prefix cloud setting_ids
    (valid as setting_id but rejected as tray_info_idx). Same check applied
    to current_tray_info_idx so stale slot values don't get reused as
    garbage.
  - Local-preset path now reads the printer-recognized filament_id from
    the preset's setting JSON (e.g. P4d64437) instead of falling through
    to a generic material ID.
  - Derive setting_id from filament_id_to_setting_id when empty so
    ams_filament_setting always carries a matched pair.
  - No stored k-profile: always send cali_idx=-1 (Default K), regardless
    of the live cali_idx on the slot. The live value belongs to whatever
    filament was there before, so reusing it would apply the wrong K to
    the new spool.

  Frontend (spool-form/utils.ts):
  - Local preset options use String(preset.id) as the unique code instead
    of preset.filament_type — every PLA local preset was collapsing onto
    the same "PLA" code, so picking any of them saved slicer_filament=
    "PLA" and lost the specific preset identity.

  Spoolman counterpart in spoolman_inventory.py mirrors the cali_idx=-1
  reset.
2026-05-14 19:20:58 +02:00
maziggy 5150a5a0fa fix(inventory): raise AssignSpoolModal z-index so it sits above the mobile sidebar drawer (#1336)
Both used z-50, so DOM stacking-order let the sidebar bleed over the
  modal on narrow viewports. Bumped to z-[100] to match the convention
  used by GitHubBackupSettings, FilamentHoverCard, and the Layout
  confirmation modal.
2026-05-14 17:07:48 +02:00
maziggy 500e99a48b Post work PR #1338 2026-05-14 15:35:13 +02:00
maziggy ccf985abfc feat(slicing): build-plate override in the SliceModal (#1337)
Slicing an STL via the integrated slicer always defaulted to whatever
  curr_bed_type lived in the chosen process preset (typically "Cool
  Plate"), which the slicer CLI rejected for high-temp filaments with
  "Plate 1: Cool Plate does not support filament 1". The user had no
  way to switch plates without cloning the preset in BambuStudio.

  The Slice modal now exposes a Build plate dropdown with the six
  canonical BambuStudio / OrcaSlicer plates (Cool Plate, Cool Plate
  SuperTack, Engineering Plate, High Temp Plate, Textured PEI Plate,
  Smooth PEI Plate) plus an "Auto (use process preset)" option that
  preserves the previous behavior. Positioned between Process profile
  and Filament rows so a long filament list never pushes it off the
  modal's scrolled viewport, and always enabled regardless of whether
  the user picked a Printer Preset Bundle.

  A new bed_type field on SliceRequest flows through both dispatch
  paths:
  - Resolved-preset path: _patch_process_bed_type overwrites
    curr_bed_type on the process JSON before forwarding to the sidecar.
    Works end-to-end today, no sidecar change needed.
  - Bundle dispatch path: slice_with_bundle adds a bedType form field
    to the sidecar multipart. The sidecar (maziggy/orca-slicer-api
    fork) needs a matching change to honor it as --curr_bed_type on
    the CLI invocation; until then the field is silently ignored and
    the slice runs with the bundle's default plate.
2026-05-14 15:28:41 +02:00
maziggy b51ef33472 fix(inventory): apply catalog color's gradient + effect, not just hex (#1340)
Picking a color preset from the catalog only copied color_name and
  rgba onto the spool — extra_colors (gradient stops) and effect_type
  (sparkle / wood / etc.) were silently dropped at three layers above
  the API: the SpoolFormModal state shape, the CatalogDisplayColor
  mapping in ColorSection, and the selectColor handler itself. All
  three widened to carry both fields through.

  Picking a catalog swatch now writes both fields from the entry, so
  solid presets cleanly replace previous gradients. Recent-colors and
  the hardcoded-fallback palette stay as plain hex pickers — they
  don't touch extras/effect since they aren't full presets.

  Also fixed the en-US `colour` → `color` drift in 8 locale files
  that the reporter flagged.
2026-05-14 14:52:18 +02:00
maziggy 5c17cd4973 fix(inventory): restore Spoolman spool ID search + Unassign button (#1336)
Two regressions reported in #1336:

  1. spoolMatchesQuery did not include spool.id in the predicate, so
     typing a numeric Spoolman ID into the Assign Spool dialog or the
     Inventory page search returned no matches. Predicate now also
     tests String(spool.id).includes(q).

  2. The Unassign button in the spool edit modal was permanently
     disabled for Spoolman-mode spools. The modal only ever queried
     the legacy spool_assignments table (keyed by spool_id), but in
     Spoolman mode the assignment lives in spoolman_slot_assignments
     (keyed by spoolman_spool_id). Both the lookup query and the
     unassign mutation now branch on the spoolmanMode prop and call
     the Spoolman-flavored endpoints.
2026-05-14 14:19:12 +02:00
maziggy b8e350c3bf fix(spoolman): persist color_name edits and stop form round-tripping the subtype synth fallback (#1319)
Editing a spool's color name on Spoolman-backed inventory appeared to
  accept the new value but the inventory list column and the next edit
  showed it back to the subtype. Three layers stacked to produce this:

  1. find_or_create_filament matches by material/name/color_hex/vendor —
     color_name is intentionally not part of the match key, but on a
     match it returned the existing filament's id unchanged, silently
     dropping the new value.
  2. The read helper falls back to subtype when filament.color_name is
     empty (kept on purpose: without it Spoolman installs that don't
     fill the field render every spool as "Unknown color").
  3. The edit form prefilled color_name from spool.color_name — which
     on those installs was the synth value. Changing subtype but not
     color_name silently round-tripped the OLD subtype back to Spoolman
     as if it were a real user-set color_name.

  Fixes:
  - find_or_create_filament now patches the matched filament's
    color_name via the existing patch_filament wrapper when the request
    differs. Parameter convention: None = don't touch, "" = explicit
    clear, any other string = set/update. A patch failure is logged but
    does not block the match.
  - The PATCH route uses model_fields_set to distinguish "field omitted"
    from "field explicitly set to null" (mirrors the existing
    storage_location pattern at the same site).
  - The map helper returns color_name_is_synthesized: bool. The edit
    form leaves the input blank when true, so the user sees the real
    stored state and can't accidentally round-trip the synth value back.
2026-05-14 08:27:06 +02:00
maziggy d08183278f fix(users): show password rules in form + match FE check to BE (#1303)
Create/Edit User modal previously had no hint about backend password
  complexity, and the FE pre-check was only length>=6 — so any weak
  password got bounced as a bare "HTTP 422" toast after the round-trip.

  - New checkPasswordComplexity util mirroring backend's validator order
  - Helper text under password inputs in both modals
  - Submit disabled until every rule passes
  - client.ts 422 parser falls back to JSON.stringify(detail) when the
    mapped array is empty, so a bare status code never masks the real
    Pydantic detail again
  - i18n keys added in all 8 locales (EN/DE fully translated, others
    per project's English-seed convention)
  - 7 unit tests pinning the validator contract, including the
    reporter's "12345678" input
2026-05-13 09:50:42 +02:00
maziggy a42ec820f6 fix(makerworld): point Open Cloud settings link to /profiles (#1300)
The "Open Cloud settings" link in the MakerWorld sign-in-required banner
  pointed at /settings?tab=cloud, but Settings has no cloud tab so the URL
  fell back to the General tab. Bambu Cloud login lives on /profiles, which
  already defaults its sub-tab to cloud.
2026-05-13 09:08:30 +02:00
maziggy e2e567f4dd fix(i18n): add settings.ldap.advanced key in all 8 locales (#1297)
The "Advanced" section header in LDAP settings was always
  rendering the hardcoded English fallback because the translation
  key was never defined. Added in en/de/fr/it/ja/pt-BR/zh-CN/zh-TW.
2026-05-12 16:38:56 +02:00
maziggy 8a6fcf5cbd fix(settings): expose UI rendering fields without requiring SETTINGS_READ (#1293)
The Clear Plate button (and 4 other features on the Printers page) read
  their state from /settings, which requires SETTINGS_READ. Granting that
  permission also adds the Settings nav item and leaks SMTP/LDAP/MQTT
  credentials — exactly what users were trying to avoid by giving an
  operator only printers:clear_plate.

  New /settings/ui-preferences endpoint returns a curated, opt-in subset
  of non-sensitive fields. Matches the existing /default-sidebar-order
  precedent. PrintersPage switched to the new endpoint; admin pages still
  use /settings for full access.
2026-05-12 16:30:28 +02:00
Ed 14a6d294f4 Merge pull request #1272 from EdwardChamberlain/feat-page-icons
[Feature] Update page icons for consistent UI style
2026-05-12 14:01:37 +02:00
maziggy bf2b34ebcf fix(ui): round smart-plug live wattage on printer card (#1266)
Plugs reporting fractional watts (Kauf PLF12 / ESPHome via MQTT)
  overflowed the card width. SmartPlugCard and SwitchbarPopover
  already round the same field; only the printer-card badge was raw.
2026-05-12 07:37:13 +02:00
maziggy 90c3efece9 Housekeeping 2026-05-11 10:43:43 +02:00
maziggy dfd9fcedf4 fix(printer-card): confirm before HA entity toggle on printer card (#1260)
Smart plugs with "Show on Printer Card" enabled appear as a chip in the
  HA-entities row below the main plug controls. One click cut power to the
  printer instantly — including mid-print — while the main Off button right
  next to it already routes through a ConfirmModal. The HA-row chip was
  added later and skipped the same gating.

  Branch on entity type: script.* entities keep firing instantly (fire-once
  triggers, not power switches — confirming each click would be annoying),
  but switch/light/anything-else entities now open a ConfirmModal first.
  Reuses the same variant="danger" + running-print warning copy as the
  existing power-off confirmation when status.state === 'RUNNING'.
2026-05-11 08:26:51 +02:00
maziggy ba3dc613d1 Changed app defaults 2026-05-10 11:15:47 +02:00
maziggy 080176c6e5 fix(filament-mapping): X2D/H2D dual-nozzle without AMS lost
external-spool extruder routing (#1257)

  X2D with 0 AMS units and two external spools (Ext-L feeding left
  extruder, Ext-R feeding right) showed "Required filament type not
  found in printer" even when the matching filament was physically
  loaded. Cause: useFilamentMapping derived dual-nozzle status from
  ams_extruder_map being non-empty -- that map is populated from AMS
  info bits, so dual-nozzle printers without AMS got an empty map
  and hasDualNozzle=false. External spools then fell through to
  extruderId=undefined, and the nozzle-aware filter rejected every
  candidate because undefined !== 0/1.

  Prefer the hardware-reported printerStatus.nozzles array length as
  the dual-nozzle signal -- populated regardless of AMS configuration
  -- and keep the ams_extruder_map branch as fallback for older
  firmware that might not surface nozzles. Affects all dual-nozzle
  printers running without AMS: X2D, H2D, X2 Pro.

  Regression test pins both layers the bug straddled --
  buildLoadedFilaments extruderId assignment per external spool, and
  computeAmsMapping picking the correct external for a per-nozzle
  requirement -- so a future change that re-breaks either fails CI.
2026-05-10 10:15:07 +02:00
maziggy 79d54a8d53 feat(archives): build-plate icon on cards + uniform printer/model line (#1253)
Show an OrcaSlicer-style bed icon in the archive card's printer-name row
  indicating which build plate the print was sliced for (Cool /
  Cool SuperTack / Engineering / High Temp / Textured PEI / Smooth PEI),
  with the full plate name in the hover tooltip. Closes the gap where
  users had to remember which plate matched a re-print or open the
  source 3MF in a slicer just to read the bed setting.

  Card row also unified: archives with a real Bambuddy-printer
  association used to render "H2D-1 GCODE ..." while slicer-only uploads
  rendered "Sliced for X1C GCODE ..." -- same line, two different shapes.
  Drop the "Sliced for " prefix so both render as a uniform
  "<name-or-model> [bed-icon] GCODE <hash>" row, scanning identically
  regardless of provenance.

  Backend: new bed_type column on print_archives (idempotent ALTER TABLE
  migration; SQLite + Postgres safe). Populated from curr_bed_type in
  Metadata/slice_info.config (per-plate, authoritative -- that's what
  got sent to the printer for the exported plate) with a fallback to
  project_settings.config for older 3MF shapes. Wired through both
  archive_to_response() (the hand-rolled dict converter that bypasses
  from_attributes -- easy to miss) and the /rescan endpoint, so old
  archives can be re-parsed via the existing per-archive Rescan button.

  Backfill script (scripts/backfill_archive_bed_type.py, --dry-run
  supported) re-opens every NULL archive's 3MF on disk to populate the
  column. Auto-loads .env from project root before importing backend
  modules (config.py reads DATABASE_URL from os.environ at import time,
  not from pydantic-settings at Settings() time) and prints the resolved
  DB URL with credentials redacted, so operators can confirm they're
  hitting the intended database -- Postgres or SQLite.

  Frontend: 6 OrcaSlicer-style PNGs ship in frontend/public/img/bed/ --
  under /img/ because that path is already statically mounted; a
  toplevel /bed-icons/ tried first hit the SPA catch-all and returned
  index.html as text/html. New utils/bedType.ts maps slicer strings
  case-insensitively, covering both Bambu Studio and OrcaSlicer naming
  variants for the same physical plate. Unmapped or NULL bed_type
  simply omits the icon, so cards stay clean for pre-feature archives.
2026-05-10 09:54:10 +02:00
maziggy 77bf53b7f1 fix(gcode-viewer): add in-app back button (was browser-back only)
Opening the GCode Viewer from a File Manager card or Archive card mounts
  GCodeViewerPage as a full-height iframe inside the Layout shell. The page
  rendered nothing but the iframe, so once the third-party viewer's UI took
  over the content area there was no in-app affordance to return to the
  originating list - only the browser's back button.

  Add a thin bar above the iframe with an ArrowLeft button. The label adapts
  to the entry point - "Back to Print Archives" when the URL carries
  ?archive=, "Back to File Manager" when it carries ?library_file=, generic
  "Back" otherwise. Click prefers navigate(-1) so the user lands back in
  their original list with scroll position and filters preserved; falls
  back to /archives or /files when the page was opened in a fresh tab and
  there's no SPA history to return to.

  New gcodeViewer.{back, backToArchives, backToFiles} i18n namespace added
  to all 8 locales with native translations.
2026-05-09 12:41:13 +02:00
maziggy 83a83ed724 feat(labels): add 40x30 mm template, hex colour code, bolder brand (issue #809 follow-up)
Three enhancements requested by @oliboehm after the V1 label-printing
  ship in #809:

  - New box_40x30 single-label template (common DK/Brother roll size,
    good for filament-bag and storage-bin labels). Routes through the
    existing roomy layout since height >= 20 mm.

  - Colour hex code (#RRGGBB, alpha-stripped, uppercase) rendered on
    every label - useful when several near-identical material/colour
    spools sit next to each other and the swatch alone isn't enough to
    tell them apart. Skipped silently when rgba is None or malformed.

  - Brand line bumped to Helvetica-Bold (was regular) and a couple of
    points larger on both layouts so it reads cleanly at arm's length.

  Wired through the SpoolLabelTemplate union, the modal's
  TEMPLATE_OPTIONS, and the inventory.labels.templates.box40x30 i18n
  key in all 8 locales (native translations for de/fr/it/ja/pt-BR/
  zh-CN/zh-TW). Modal regression test widened from 4 to 5 template
  buttons. Three new renderer tests pin the hex-code render, the
  hex-code skip on invalid rgba, and the bold-brand font reference.
2026-05-09 10:10:20 +02:00
maziggy a193145522 fix(archives): hide truncated "Re..." / "Sc..." button labels on narrow card widths (issue #1249)
The archive card's action row crams 6 buttons into one line: 2 labelled
  (Reprint + Schedule, or Slice when un-sliced) plus 4 icon-only utilities.
  The labelled buttons used `flex-1` to share whatever the icon buttons
  left over, with the label gated on `hidden sm:inline truncate`.

  Tailwind viewport breakpoints can't see the card width. The grid grows
  columns alongside viewport (md:2 lg:3 xl:4), so cards stay ~320-380 px
  wide regardless of breakpoint, and the labelled buttons end up with
  ~30 px of space — enough to render "Re..." / "Sc..." and not much else.

  Bump the label breakpoint sm: -> xl: so labels appear only at
  viewport >= 1280px where the cards actually have room. Below that,
  the buttons render icon-only and the existing title= attribute serves
  as the hover tooltip.
2026-05-09 09:53:25 +02:00
Miguel Ángel López Vicente 6a130c09d7 [Feature] Copy filament (#1246) 2026-05-09 09:40:21 +02:00
maziggy 30fe88a334 fix(inventory): show all per-printer/per-nozzle variants in spool form's Slicer Preset dropdown (issue #1248)
Two defects in buildFilamentOptions, surfaced together:

  1. The function was precedence-based — cloud presets short-circuited
     the local-presets branch, silently hiding any imported Local Profile
     while the user was logged into Bambu Cloud. The wiki documents the
     dropdown as "merged and deduplicated" across cloud + local + built-in.

  2. Cloud default presets and local presets were being collapsed by base
     name (everything after "@" stripped), so all P1S/X1C/A1 variants of
     "Bambu PLA Basic" rendered as a single row. The spool form is
     printer-agnostic by design, so the right semantic is to show every
     variant individually — the union across all printers — not collapse
     them. AMS Slot is per-printer (it filters), the spool form is
     union-of-all (it doesn't).

  Rewrote the merge to push each cloud setting_id and each LocalPreset row
  as its own FilamentOption with the full @printer suffix preserved in
  displayName. Built-in dedup against cloud setting_id is kept (mirrors
  ConfigureAmsSlotModal.tsx). Wired api.getBuiltinFilaments() into both
  callers. slicer_filament persistence is unchanged so existing spools
  keep slicing correctly.
2026-05-09 08:43:51 +02:00
MartinNYHC b30a283184 Feature/spoolman inventory UI (#1241)
feat(spoolman-inventory): squashed feature work for rebase onto dev

Squashed all commits from feature/spoolman-inventory-ui onto a single commit
to enable a clean rebase onto dev. Original per-commit history preserved at
backup tag backup/spoolman-inventory-ui-prerebase-20260507-105721.
2026-05-08 11:52:42 +02:00
maziggy 554f5172f0 Post work PR #1219 2026-05-08 09:06:18 +02:00
Sn0rrii 90743cfa39 feat(encryption): MFA at-rest encryption auto-bootstrap with status UI (#1219) (#1231)
chore(i18n): extend parity gate to all locales with strict/info tiers

  Previously the script only inspected en/zh-CN/zh-TW, leaving de/fr/it/ja/pt-BR
  drift invisible. Now locales are auto-discovered from src/i18n/locales/, and a
  STRICT list (de, zh-CN, zh-TW — currently in parity) gates CI while the rest
  report informationally until their drift is caught up. ja notably has 27 real
  placeholder bugs worth fixing before promotion to strict.
2026-05-08 09:01:51 +02:00
maziggy bb03a2b373 fix(frontend): revert base: '' so deep SPA routes load their assets on initial navigation (issue #1221)
PR #1195 (d6a31393) set Vite's base: '' to emit relative asset URLs
  in the built index.html, intended as a partial improvement for
  path-prefixed reverse proxies. The relative URLs broke every deep
  SPA route on initial load: popup windows, direct URL paste, page
  refresh on /camera/<id>, /projects/<id>, /groups/<id>/edit,
  /external/<id>, /files/trash, and the SpoolBuddy kiosk paths.

  Browser resolves ./assets/index-XXX.js against the document URL.
  For /camera/<id>, that gives /camera/assets/index-XXX.js — the SPA
  catch-all returns index.html (text/html) for that path, and modern
  browsers refuse to execute HTML as a JS module under
  X-Content-Type-Options: nosniff. Hence the empty-popup symptom
  reported on #1221 across P1S / P2S / X1 / Docker / git / Chrome /
  Firefox / Brave / Safari, plus the quieter "blank page on refresh"
  on every other deep route.

  Reverts the two PR #1195 lines: removes base: '' from
  vite.config.ts (Vite default '/' restored, emitting absolute asset
  URLs /assets/..., /manifest.json, /sw-register.js) and reverts
  register('sw.js') to register('/sw.js') in public/sw-register.js.

  PR #1195's class of bug — path-prefixed reverse proxy users serving
  Bambuddy at a subpath — was already explicitly closed as wontfix in
  that thread because supporting it requires subpath-aware
  bootstrapping (API_BASE, React Router basename, PWA manifest scope,
  SW scope) for every user forever. The supported alternative for that
  audience stays as documented in the #1195 closing comment: NPM
  (Nginx Proxy Manager) addon + Cloudflare Tunnel at a real domain
  with HTTPS, then HA Webpage panel embedding via
  TRUSTED_FRAME_ORIGINS — that path doesn't depend on base: '' at all.

  The trade-off is intentional: revert reaches every user impacted by
  deep-route initial-load bugs (much larger population than
  path-prefixed proxy users), in exchange for an already-wontfixed
  subpath-proxy regression that has a working alternative.
2026-05-08 08:39:00 +02:00
maziggy 4c0a12b95e fix(label-picker): pack templates into a 2x2 grid so all 4 plus Cancel fit on tight viewports (issue #1230)
The earlier `min-h-0` fix on the spool list (61314cf2) made the
  shrinkable child shrinkable, but on @elit3ge's 838px viewport the
  four stacked templates (~310px) plus footer still blew past
  max-h-[90vh] once Brave's browser chrome ate into vh, and
  overflow-hidden on the modal clipped Avery 5160 mid-row with the
  Cancel button entirely below the clipped bottom edge — no scroll
  path. The screenshot showed the spool list at ~5 visible rows with
  its own scrollbar still active, confirming the templates section's
  natural height was the dominant problem, not the spool list.

  Templates now render as a responsive grid (grid-cols-1
  sm:grid-cols-2 gap-2) so the four buttons pack into a 2x2 grid
  above the sm breakpoint, trimming ~150px of vertical. Per-cell
  padding tightens to p-2.5, labels/hints get text-sm + truncate,
  and the full strings are reachable via title="<label> — <hint>"
  on each button. Footer drops py-3 to py-2 for a few extra pixels.
  The min-h-0 on the spool list is kept as a belt-and-braces shrink
  for any viewport tighter still. Mobile (<sm) keeps the stacked
  layout — no regression there.
2026-05-08 07:27:35 +02:00
maziggy 20fa8fbfdc fix(configure-ams-slot): expand long filament profile names inline on hover (issue #1237)
Long preset names like "SUNLU PETG GLOW IN THE DARK GEN2 @Bambu Lab
  H2C 0.4 nozzle" were visually clipped in the Configure AMS Slot
  modal's preset picker. With several near-identical entries differing
  only in nozzle size, users had to open browser dev tools to tell
  them apart.

  A `title={preset.name}` alone was too slow visually — browsers wait
  500-1000ms before rendering native tooltips. The row now un-truncates
  inline on hover via group-hover:whitespace-normal + break-all, so the
  full name appears the moment the cursor enters the row. `truncate`
  stays as the default to keep the list compact when scanning.

  The native `title={preset.name}` is also kept as a belt-and-braces
  fallback for assistive tech and touch devices where :hover doesn't
  fire. Both desktop and mobile layouts updated.

  Test: new ConfigureAmsSlotModal.test.tsx regression that pins the
  truncate / group-hover:whitespace-normal / group-hover:break-all
  classes on the span, the title attribute, and the `group` class on
  the parent button — so a future refactor that drops any of those
  fails CI.
2026-05-08 07:15:23 +02:00
MartinNYHC dac2a31192 Revert "feat(inventory): unified Spoolman inventory UI + AMS slot assignments…" (#1232)
This reverts commit 55d71498e9.
2026-05-07 11:30:31 +02:00
Sn0rrii 55d71498e9 feat(inventory): unified Spoolman inventory UI + AMS slot assignments + Storage Location + NFC write support + Spoolman Filament Catalog Picker (#1114)
feat(spoolman-inventory): squashed feature work for rebase onto dev

Squashed all commits from feature/spoolman-inventory-ui onto a single commit
to enable a clean rebase onto dev. Original per-commit history preserved at
backup tag backup/spoolman-inventory-ui-prerebase-20260507-105721.
2026-05-07 11:15:24 +02:00
maziggy ded161626a Post work PR #1203 2026-05-07 10:48:49 +02:00
maziggy a50958e426 feat(slice-modal): Bundle tier for picking presets from imported .bbscfg
Closes the loop on the bundle work: users who imported a Printer
  Preset Bundle via Settings → Slicer Bundles can now pick it in the
  SliceModal and slice through the bundle dispatch path the backend
  already supports.

  UX:
  - New "Slicer bundle" picker at the top of the modal, rendered only
    when at least one bundle is imported (GET /slicer/bundles non-empty)
  - Selecting a bundle replaces cloud/local/standard preset dropdowns
    with bundle-scoped pickers (process + per-slot filament names from
    the bundle). Printer is implicit (each .bbscfg has exactly one).
  - Submit routes through SliceRequest.bundle so the backend skips
    PresetRef resolution and asks the sidecar to materialise the JSON
    triplet from the stored bundle by name.
  - "None" leaves the modal on the original preset triplet path.

  Frontend types: SliceBundleSpec + bundle?: SliceBundleSpec on SliceRequest.
2026-05-06 09:55:14 +02:00
maziggy a64cfbbd36 fix(slice-modal): disable Slice button until preview slice resolves
Until the preview slice / embedded-metadata read returns the per-plate
  filament list, the modal renders a synthetic single-slot fallback so
  the auto-pick has something to bind against. That made the Slice button
  enabled the moment the modal opened, even before the slicer had told us
  which AMS slots the plate actually consumes — clicking would dispatch
  against opaque defaults.

  Add filamentReqsQuery.isSuccess to the isReady chain so the button
  stays disabled while the preview slice is in flight (or before the
  backend's /filament-requirements call settles for sliced files) and
  flips to enabled the moment the real slot list lands and auto-pick
  fills it.
2026-05-06 09:27:47 +02:00
maziggy 8a31397171 feat(slicer): bundle-aware preview slice for accurate gram estimates
The SliceModal's preview slice runs against unsliced project files to
  discover per-plate AMS slot consumption. Until now it always used
  slice_without_profiles — accurate slot mapping (a model property) but
  gram numbers were derived from the file's embedded process settings,
  which can drift from the triplet the real print will use.

  When the caller provides a bundle id + printer/process/filament preset
  names, get_preview_filaments now routes through slice_with_bundle so
  the preview's gram numbers match what the real print will produce.
  Cache key picks up a bundle-context fingerprint so different bundle
  picks on the same file occupy distinct entries.

  Backend:
  - slice_preview.get_preview_filaments: optional bundle_* params
  - library.py + archives.py: forward params via /filament-requirements

  Frontend (forward-compat for the upcoming SliceModal Bundle tier):
  - api.getLibraryFileFilamentRequirements / getArchiveFilamentRequirements
    accept an optional 4th-arg bundle context object
2026-05-06 09:10:35 +02:00
maziggy 864e5c990e feat(inventory): printable PDF spool labels in 4 sizes (#809)
Closes the longest-standing inventory gap — finding a specific spool
  in a closet of 50 partials. Per-spool icon button on every inventory
  card and table row, plus a "Print labels..." header action that opens
  a multi-select picker pre-loaded with the currently filtered spools.

  Four pre-built templates: AMS holder (30 x 15 mm) for the popular
  Makerworld AMS Filament Label Holder, single box label (62 x 29 mm)
  for Brother PT/QL or Dymo small labels, Avery L7160 (A4, 21 per
  sheet), and Avery 5160 (US Letter, 30 per sheet). Each label carries
  the colour swatch (with multi-colour gradient stripes for spools
  with extra_colors set), brand, material, name, the *spool ID*
  (bsaunder's articulated user-need: telling 8 spools of "PLA White"
  apart, especially partials), and a QR code that deep-links to
  /inventory?spool=<id> for phone-scan round-trips. Box-label adds
  storage location; AMS-holder drops the QR — at 30 x 15 mm there is
  no room for swatch + text + QR without truncating away the spool ID,
  and AMS-bay identification is at arm's length where the swatch and
  ID are enough.

  Server-side rendering via ReportLab + qrcode (already a dep). Pure
  Python, no headless browser, no system libs. Output is byte-identical
  across browsers, Avery sheets align to <0.1 mm, and bulk export is
  one click for one PDF. Two endpoints — POST /inventory/labels (local
  DB) and POST /spoolman/labels (Spoolman-backed) — gated on
  INVENTORY_READ, capped at 500 spools per request, returning
  application/pdf via StreamingResponse. The renderer is decoupled
  from the SQLAlchemy model via a LabelData dataclass so the same code
  path serves both modes.

  Modal picker scales to large libraries: search (substring match
  across name / brand / #ID), material filter chips derived from the
  visible spools, additive Select-all-visible / Deselect-visible /
  Clear-all actions so selections survive filter changes. Restyled
  twice in development — first cut used generic Tailwind which clashed
  with the inventory's bambu-dark palette; second cut switched to
  bambu-dark-secondary / bambu-green / bambu-gray to match.

  Two render bugs found during visual inspection of generated PDFs and
  fixed before commit:

    1. AMS-30x15 template originally produced labels with only swatch
       + QR and no text at all — the side-by-side layout left <5 mm
       for the text column, so the renderer bailed without drawing
       anything. Layout split into tight (h<20mm) and roomy (h>=20mm)
       regimes; tight regime drops the QR and gives the right column
       to brand + material + a 13pt-bold spool ID.

    2. Box-62x29 template aggressively truncated text — swatch + QR
       each at ~14 mm on a 26mm-tall label squeezed the text column
       to ~16 mm, turning "Polymaker Ivory" into "Polymak..." and
       "Polymaker . PLA . Matte" into "Polymaker ...". Swatch capped
       at 16 mm, QR capped at 18 mm and constrained to ~20% of width,
       leaving the text column ~30 mm — full names render without
       truncation.

  Both bugs pinned by regression tests in test_label_renderer.py that
  render with pageCompression=0 so the resulting PDF bytes contain the
  text as ASCII and `assert b"Polymaker" in pdf` works.
2026-05-05 12:29:57 +02:00
maziggy 907fbec138 Housekeeping 2026-05-05 09:54:15 +02:00
maziggy b42aaca521 fix(spool-assign): defer MQTT for empty AMS slot, replay on physical insert
The SpoolBuddy "weigh-then-assign" workflow tried to configure an empty AMS
  slot at assign time, but Bambu firmware silently drops ams_filament_setting
  and extrusion_cali_sel for unloaded slots — the MQTT calls completed and the
  modal closed, yet BambuStudio kept showing the slot as default-PLA forever.

  assign_spool now detects an empty target slot (fingerprint_type empty) and
  persists the SpoolAssignment without publishing MQTT, returning a new
  pending_config flag so the frontend can swap "Assigned!" for "Slot will
  configure when you insert the spool." on_ams_change watches for the slot
  to load (state == 11, which fires for 3rd-party tags too even when
  tray_type stays empty) and replays the deferred ams_filament_setting +
  extrusion_cali_sel — including the printer-kp realignment that converts
  PFUS-prefix cloud user presets to the P-prefix local-preset filament_id
  the slicer actually accepts.

  The full assign-time MQTT block was extracted into
  apply_spool_to_slot_via_mqtt so both the assign endpoint and the
  on_ams_change replay path use the same resolution logic; the helper takes
  ~270 lines of duplication out of assign_spool.
2026-05-04 16:02:18 +02:00
maziggy 8986343999 Revert "."
This reverts commit e05c8a1dca.
2026-05-04 10:59:38 +02:00
maziggy e05c8a1dca . 2026-05-04 10:57:36 +02:00
maziggy 64899a8ca4 refactor(virtual-printer): drop Tailscale LE cert path, keep toggle informational
The Tailscale toggle was supposed to obtain a publicly-trusted Let's Encrypt
cert via `tailscale cert` so users wouldn't need to import Bambuddy's CA into
the slicer. End-to-end testing showed this was always going to fail:

  - Bambu Studio and OrcaSlicer refuse hostname input in the Add Printer
    dialog (IP-only).
  - Their printer-MQTT trust path validates only against the bundled BBL CA
    store (`printer.cer`), NOT the system trust store. Confirmed against
    ClusterM/open-bambu-networking's clean-room reimplementation:
    `mosquitto_tls_set(BBL_CA)` + `verify_peer=1` + `tls_insecure=true` —
    chain validation against BBL CA only, hostname check intentionally
    skipped (because Bambu's printer cert CN is the device serial).
  - LE certs don't chain to BBL CA, so the slicer rejects with the
    well-known "-1" before any hostname/IP logic runs.

The cert-import step is unavoidable; LE provisioning was dead code for slicer
connections. Pivot:

  - Toggle stays as an informational marker — when ON, the VP card surfaces
    the host's Tailscale IP + MagicDNS hostname so users know what to paste
    into the slicer.
  - Cert is always self-signed (signed by `bbl_ca`).
  - Tailscale exposure is via the existing bind_ip dropdown, which already
    includes `tailscale0` IPs.
  - Tailscale's role is strictly network reach — same trust burden as LAN.

Backend cuts:

  - `tailscale.py`: `provision_cert`, `ensure_cert`, `cert_needs_renewal`,
    `_FQDN_RE`, `_HTTPS_DISABLED_RE`, `TS_CERT_EXPIRY_THRESHOLD_DAYS`,
    `cryptography` import. Keep `get_status` and `TailscaleStatus`.
  - `certificate.py`: `ts_cert_path`, `ts_key_path`, `use_tailscale_cert`.
  - `manager.py`: `tailscale_fqdn` field, `_cert_renewal_task`,
    `_cert_restart_task`, `_cert_renewal_loop`, `_restart_for_cert_renewal`,
    `_cancel_renewal_task`, `_cancel_restart_task`. Simplify
    `_resolve_cert_and_advertise` to a sync method that just generates the
    self-signed cert. Drop `tailscale_disabled` from the change-detection
    diff (toggle is informational — no service restart needed).
  - `routes/virtual_printers.py` + `routes/settings.py`: drop the
    `tailscale_not_available` 409 guard on toggle-enable.

Frontend cuts:

  - `VirtualPrinterCard.tsx`: FQDN/IP display sourced from
    `multiVirtualPrinterApi.getTailscaleStatus()` (host-level) when toggle
    is ON, instead of `printer.status.tailscale_fqdn` (cert side-effect,
    no longer populated). Drop the `tailscale_not_available` toast handler.
  - `api/client.ts`: drop `tailscale_fqdn` from the VP status type.
  - i18n: rewrite `tailscaleDisabled.description` in all 8 locales to drop
    the "no cert import" promise. Remove `toast.tailscaleNotAvailable` key.

Docs:

  - Wiki `features/virtual-printer.md`: rewrite the entire Tailscale section
    — remove the LE-cert + HTTPS-Certs-toggle + tailscale-cert-operator
    steps, document the toggle as informational, keep the Docker socket
    mount + LXC TUN troubleshooting (those still apply for daemon
    reachability).
  - README: drop "the Tailscale benefit here is the tunnel, not cert-import
    elimination" framing in favour of "surfaces the IP for paste into
    slicer; CA import unchanged because BBL CA store, not system trust
    store, is what gets validated".

Tests:

  - `test_tailscale.py`: reduced to surviving `get_status` cases (binary
    missing, command fails, success, empty DNSName, malformed JSON).
  - `test_virtual_printer.py::test_sync_from_db_restarts_on_tailscale_disabled_change`
    → `test_sync_from_db_does_not_restart_on_tailscale_toggle` (toggle is
    informational; `remove_instance` must NOT be called).
  - `test_virtual_printer_api.py::TestVirtualPrinterTailscaleGuardAPI` →
    `TestVirtualPrinterTailscaleToggleAPI` (single test asserts both
    directions succeed and daemon is never consulted).
  - `VirtualPrinterCard.test.tsx`: mock now stubs `getTailscaleStatus`;
    FQDN-copy block drives data through that query.

DB column `tailscale_disabled` is kept (persists toggle state) — Postgres-
safe column drop is harder; future cleanup can remove if the toggle goes
away entirely. LE cert files on disk (`virtual_printer_ts.{crt,key}`) are
left in place per VP — harmless residue, manual cleanup if desired.

Verified: ruff clean, 2484 backend unit tests pass, 17 frontend VP-card
tests pass, frontend build succeeds, live service restart confirms VPs
serve `issuer=CN=Virtual Printer CA` on the Tailscale interface — slicer
trusts the user-imported bambuddy CA and skips hostname checks, so MQTT
connection succeeds end-to-end.
2026-05-03 14:58:29 +02:00
maziggy a6c53798d4 fix(notifications): print-complete duration uses actual elapsed, not slicer estimate (#1198)
Pre-fix, _background_notifications in main.py:3434 built archive_data
  with print_time_seconds (the slicer's pre-print estimate parsed from
  the 3MF at archive creation), and notification_service.py:909 formatted
  that field straight into the {{duration}} template variable. A print
  cancelled 2 minutes into a 3-hour estimate notified "duration: 3h".

  Compute actual_time_seconds from started_at/completed_at in main.py and
  add it to archive_data. notification_service.py prefers it, falls back
  to print_time_seconds when the actual can't be derived.

  Also add "cancelled" to the list of statuses that get completed_at set
  in update_archive_status — pre-fix only completed/failed/aborted got a
  timestamp, so queue-UI cancellations had no actual elapsed to compute
  from. Audited every completed_at consumer; none depend on NULL to mean
  "cancelled" (status field already carries that signal), and the
  statistics-totals aggregation gets more accurate too as a side effect.

  3 new regression tests in TestNotificationVariableFallbacks pin the
  {{duration}} variable contract (actual wins over estimate; estimate
  falls in when actual is missing; "Unknown" when both absent).
2026-05-03 08:33:00 +02:00
maziggy abc8e97050 feat(camera): optional snapshot URL override for external cameras (#1177)
go2rtc and several IP cameras still emit a warm-up / black frame on every
  fresh MJPEG connection — even with the v0.2.4b2 warm-up-skip fix it
  slipped through intermittently for @nkm8's setup. His own bisect named
  the clean solution: go2rtc exposes /api/frame.jpeg as a dedicated
  single-frame endpoint that never returns the encoder's stale keyframe.

  Adds an optional external_camera_snapshot_url column on printers. When
  set, every single-frame capture path (snapshot endpoint, [SNAPSHOT]
  notification thumbnails, [PHOTO-BG] finish photo, layer timelapse,
  Obico ML, plate-detect / calibrate-plate) routes through _capture_snapshot
  on the override URL via plain HTTP GET, bypassing the warm-up dance.

  Live view stays on the configured stream URL — only single-frame
  captures use the override. Override is camera-type-agnostic. SSRF guard
  applies (existing _sanitize_camera_url allowlist). Empty string treated
  as unset.

  Settings UI: new "Snapshot URL (optional)" input + Test button under
  External Cameras, hidden for camera_type=snapshot since the live URL is
  already a single-frame source. en + de fully translated; 6 other locales
  seeded with English copy.

  5 backend tests pin the routing contract; 3 frontend tests pin the
  input + debounced PATCH. Documented in
  bambuddy-wiki/docs/features/camera.md with the go2rtc example.
2026-05-03 07:59:02 +02:00
maziggy d6a3139397 fix(frontend): emit relative asset paths so SPA loads under any subpath (#1195)
Vite's default base of '/' baked absolute asset URLs into the built
  index.html (/assets/..., /manifest.json, /img/..., /sw-register.js),
  so any path-prefixed reverse proxy (Traefik, nginx subpath, Cloudflare
  Tunnel with path routing) served the SPA as a blank white page — the
  browser requested assets from the host root and got HTML or text/plain
  back, triggering MIME-mismatch errors on every stylesheet/script.

  Set base: '' in vite.config.ts so the HTML transform emits relative
  URLs everywhere. Update public/sw-register.js to register('sw.js')
  (relative) so SW scope auto-pins to whatever subpath the document
  loaded from.

  Out of scope: API_BASE in client.ts is still absolute. The supported
  HA embedding path remains Webpage panel + TRUSTED_FRAME_ORIGINS, not
  HA Ingress (subpath-aware SPA bootstrapping has too many failure modes
  around PWA scope, push subscriptions, and deep-link reloads to take on
  in core). Documented explicitly in docker.md.

  Reported by @Spegeli, follow-up to #1167.
2026-05-03 07:33:40 +02:00
maziggy 875d80ad3c fix(mqtt): lift paho inflight ceiling to prevent QoS=1 session wedge (#1164)
paho's default max_inflight_messages=20 silently fills on Bambu's broker
  after ~16-20 cumulative commands per session, leaving publish() returning
  success while packets sit in paho's internal queue. force_reconnect heals
  it because the inflight queue is per-session, but it costs one wasted
  user action to trigger.

  Lifting the ceiling to 1000 keeps QoS=1 untouched (deliberately chosen
  for cross-model reliability — A1, P1S, X1C, H2D, P2S, X2D all need it)
  and removes the inflight queue as the bottleneck without changing
  wire-protocol behaviour. The 0.2.4b2 watchdog reconnect stays as
  defence-in-depth.

  Diagnosis credit: RosdasHH's QoS=1/0/2 bisect on #1164.
2026-05-02 16:12:48 +02:00