Commit Graph
3001 Commits
Author SHA1 Message Date
maziggy 6fa74be429 feat: Update printer card UI for structure and readability (#1661) 2026-06-28 12:33:05 +02:00
maziggy 18270b4a69 log(scheduler): emit prefer-lowest dispatch decision at INFO so #1766 can be triaged from a bundle
The matcher's tray_info_idx vs color vs type_only bucket choice was
      debug-only, so a bug report's bundle never showed which path won. Emit
      the sorted candidate trays and the picked bucket per filament req when
      prefer_lowest=True. Behaviour-neutral; existing 89 matcher tests pass.
2026-06-28 12:31:58 +02:00
maziggy 8283b175c0 Restrict printer secrets to update-authority callers
GET /api/v1/printers/ and /api/v1/printers/{id} return access_code
      only when the caller holds PRINTERS_UPDATE. Adds PrinterResponseWithSecret
      as the elevated response shape; PrinterResponse no longer carries the
      field. Auth-disabled single-trust mode preserved.
2026-06-28 12:31:11 +02:00
maziggy 0eed98657f fix(local-presets): optimistic remove on delete
The Slicer -> Local Profiles page kept showing a just-deleted row for
      the ~hundreds of ms it took invalidateQueries to refetch. A quick
      re-click on the same row opened a second delete-confirm modal that
      resolved to a 404 from the backend.

      Add an optimistic queryClient.setQueryData filter in deleteMutation's
      onSuccess so the row disappears the instant the DELETE returns 200.
      Existing invalidateQueries calls stay in place to reconcile any drift.

      Found while reproducing #1713 (verifying maziggy's setup against the
      reporter's). Unrelated to that investigation but caught here.
2026-06-28 12:30:49 +02:00
maziggy f7620406cb chore(frontend): vite 7 -> 8 + plugin-react 5.2
Major version bump for the frontend build:
      - vite ^7.3.2 -> ^8.0.16
      - @vitejs/plugin-react ^5.1.1 -> ^5.2.0

      Vite 8 swaps Rollup for Rolldown as the default bundler
      (Rust-backed, same plugin contract). The bump also lifts the
      transitive esbuild floor to 0.28.1, closing the last open
      advisory in the audit chain.

      vite.config.ts surface audited and unchanged:
      - defineConfig, Connect type
      - serveGcodeViewer configureServer middleware
      - server.proxy with WebSocket upgrade for /api/v1/ws
      - build.outDir / emptyOutDir / chunkSizeWarningLimit
      - resolve.alias for @
      - base: '/' regression guard from #1221

      vitest@4.1.8 already accepts vite 8 in its peer range
      (^6 || ^7 || ^8); no test-runner bump required.

      Node floor for vite 8 is ^20.19.0 || >=22.12.0; CI Node 20.x
      line satisfies this.

      Not taken: plugin-react v6 — it requires
      babel-plugin-react-compiler and @rolldown/plugin-babel as
      peers and is a separate scope.
2026-06-28 12:30:24 +02:00
maziggy 000af6830b chore(frontend): dependency bumps
Runtime:
      - dompurify 3.4.0 -> 3.4.10 (package.json floor raised from
        ^3.4.0 to ^3.4.10 so fresh installs cannot land on the
        deprecated 3.4.4 release; release notes 3.4.1 -> 3.4.10
        reviewed — the three call sites (MakerworldPage,
        ProjectDetailPage, ProjectPageModal) use string-output
        sanitisation and are unaffected by 3.4.4's widened default
        allow-list)

      Build / lint / test tooling (transitive, dev-only):
      - @babel/core 7.29.0 -> 7.29.7 (via @vitejs/plugin-react and
        eslint-plugin-react-hooks)
      - vite 7.3.2 -> 7.3.5
      - markdown-it 14.1.1 -> 14.2.0 (via @tiptap/extension-link
        -> @tiptap/pm -> prosemirror-markdown; Bambuddy never calls
        markdown-it.render directly)
      - js-yaml 4.1.1 -> 4.2.0 (via eslint)
      - form-data 4.0.5 -> 4.0.6 (via jsdom)
      - ws 8.20.1 -> 8.21.0 (via jsdom)
2026-06-28 12:29:58 +02:00
maziggy 355d08a8f6 fix(spoolbuddy): inventory search matches spool ID + storage location (#1738)
The SpoolBuddy inventory page reimplemented its filter inline and only
      matched material/subtype/brand/color_name/note, while Bambuddy's main
      inventory uses the shared filterSpoolsByQuery helper which also matches
      spool ID, slicer_filament_name, and storage_location. Delegate to the
      shared helper so both pages stay in lockstep.

      - frontend/src/pages/spoolbuddy/SpoolBuddyInventoryPage.tsx: replace
        inline filter with filterSpoolsByQuery
      - frontend/src/__tests__/pages/SpoolBuddyInventorySearch.test.ts: lock
        in ID / partial ID / pre-fix fields / parity-gain fields
2026-06-28 12:29:29 +02:00
maziggy 37d5dfe25a Housekeeping 2026-06-28 12:29:03 +02:00
maziggy 0ebd354384 . 2026-06-28 12:28:16 +02:00
maziggy 7f15088615 fix(auth/ui): sidebar accepts granular *_read tiers for archives/queue/files (#1755)
navPermissions in Layout.tsx gated three resources on the LEGACY *:read flag.
      Default Operators group is seeded with *_own only (and the migration map flips
      legacy → _own on existing groups), so non-admin users never held the legacy
      permission and the sidebar hid Archives / Queue / Files even though the
      underlying API accepted their requests. Reporter only spotted Files; same bug
      shape applied to Archives and Queue.

      Fix: navPermissions accepts Permission | Permission[]; the three affected
      resources list all three tiers. isHidden checks .some(hasPermission) for
      arrays. Permission type extended with the matching *_own / *_all variants —
      backend already shipped them, the TS type just didn't declare them.
2026-06-28 12:25:38 +02:00
maziggy 2cbbd1eed3 fix(notifications): wire on_printer_offline dispatch on disconnect edge (#1752)
The provider toggle, schema, template, and NotificationService.on_printer_offline
      all shipped, but no caller invoked the dispatcher — the offline event was an
      orphan toggle. Edge detection in on_printer_status_change now schedules a
      debounced (60s) background task on the connected→disconnected transition;
      reconnect before the window elapses cancels it. Covers both upstream paths
      (smart-plug power-off via mark_printer_offline, and MQTT staleness via
      check_staleness), both of which already route through the status callback.
      The "back online" channel is the existing print-failure notification on
      firmware FAILED report — no symmetric on_printer_online needed.
2026-06-28 12:25:25 +02:00
maziggy ed1683fe3e fix(auth): preserve original URL across login + OIDC round-trip (#1750)
ProtectedRoute and PermissionRoute now pass the requested location as
      router state when redirecting to /login. LoginPage stashes it in
      sessionStorage before the OIDC provider redirect (since window.location
      kills React state) and consumes it on all three post-login navigations
      (credentials, 2FA, OIDC token exchange). Targets are sanitized to
      same-origin internal paths only — protocol-relative and /login itself
      are rejected to prevent open-redirect.

      QR labels (https://host/inventory?spool=N) now land on the scanned
      spool instead of the printer page after authentik / any OIDC SSO login.
2026-06-28 12:25:05 +02:00
maziggy 8faaeb96e0 fix(archives): backfill NULL created_at + tolerate NULL in response (#1732)
Older print_archives rows (and rows that landed via the SQLite ↔ Postgres
      cross-DB restore path) can have created_at = NULL because the column was
      originally created without a DEFAULT clause — server_default=func.now()
      only fires at table creation, not for existing rows or raw cross-DB
      inserts. The list_archives response model required a datetime, so a
      single NULL row 500'd the whole endpoint via Pydantic ResponseValidationError.

      - Boot-time backfill: COALESCE(completed_at, started_at, now()) for
        any row where created_at IS NULL. Dialect-branched (SQLite datetime('now')
        vs Postgres NOW()).
      - Schema: created_at is now Optional on ArchiveDuplicate, ArchiveResponse,
        and ArchiveSlim so a future NULL-leaking path doesn't break the list
        endpoint again.
2026-06-28 12:24:44 +02:00
Ed 0f99b54d7e feat: Update printer card UI for structure and readability (#1661) 2026-06-18 11:46:07 +02:00
maziggy 9d1f04b89d log(scheduler): emit prefer-lowest dispatch decision at INFO so #1766 can be triaged from a bundle
The matcher's tray_info_idx vs color vs type_only bucket choice was
  debug-only, so a bug report's bundle never showed which path won. Emit
  the sorted candidate trays and the picked bucket per filament req when
  prefer_lowest=True. Behaviour-neutral; existing 89 matcher tests pass.
2026-06-18 08:22:22 +02:00
maziggy 9a432f0050 Restrict printer secrets to update-authority callers
GET /api/v1/printers/ and /api/v1/printers/{id} return access_code
  only when the caller holds PRINTERS_UPDATE. Adds PrinterResponseWithSecret
  as the elevated response shape; PrinterResponse no longer carries the
  field. Auth-disabled single-trust mode preserved.
2026-06-18 07:36:16 +02:00
maziggy 2139bc5290 Post work PR #1673 2026-06-17 12:08:08 +02:00
Ed 48d3f68ae6 feat: Centralise sidebar ordering and add page visibility options (#1673) 2026-06-17 12:04:57 +02:00
maziggy 0e8b5e032b Post work PR #1505 2026-06-17 11:38:07 +02:00
Poltavtcev af5d24e289 feat(inventory): structured storage locations catalog (#1505) 2026-06-17 11:33:23 +02:00
maziggy e8f0698ae1 fix(local-presets): optimistic remove on delete
The Slicer -> Local Profiles page kept showing a just-deleted row for
  the ~hundreds of ms it took invalidateQueries to refetch. A quick
  re-click on the same row opened a second delete-confirm modal that
  resolved to a 404 from the backend.

  Add an optimistic queryClient.setQueryData filter in deleteMutation's
  onSuccess so the row disappears the instant the DELETE returns 200.
  Existing invalidateQueries calls stay in place to reconcile any drift.

  Found while reproducing #1713 (verifying maziggy's setup against the
  reporter's). Unrelated to that investigation but caught here.
2026-06-17 09:15:38 +02:00
maziggy 249dacbd53 chore(frontend): vite 7 -> 8 + plugin-react 5.2
Major version bump for the frontend build:
  - vite ^7.3.2 -> ^8.0.16
  - @vitejs/plugin-react ^5.1.1 -> ^5.2.0

  Vite 8 swaps Rollup for Rolldown as the default bundler
  (Rust-backed, same plugin contract). The bump also lifts the
  transitive esbuild floor to 0.28.1, closing the last open
  advisory in the audit chain.

  vite.config.ts surface audited and unchanged:
  - defineConfig, Connect type
  - serveGcodeViewer configureServer middleware
  - server.proxy with WebSocket upgrade for /api/v1/ws
  - build.outDir / emptyOutDir / chunkSizeWarningLimit
  - resolve.alias for @
  - base: '/' regression guard from #1221

  vitest@4.1.8 already accepts vite 8 in its peer range
  (^6 || ^7 || ^8); no test-runner bump required.

  Node floor for vite 8 is ^20.19.0 || >=22.12.0; CI Node 20.x
  line satisfies this.

  Not taken: plugin-react v6 — it requires
  babel-plugin-react-compiler and @rolldown/plugin-babel as
  peers and is a separate scope.
2026-06-17 08:27:48 +02:00
maziggy 861de7a0e6 chore(frontend): dependency bumps
Runtime:
  - dompurify 3.4.0 -> 3.4.10 (package.json floor raised from
    ^3.4.0 to ^3.4.10 so fresh installs cannot land on the
    deprecated 3.4.4 release; release notes 3.4.1 -> 3.4.10
    reviewed — the three call sites (MakerworldPage,
    ProjectDetailPage, ProjectPageModal) use string-output
    sanitisation and are unaffected by 3.4.4's widened default
    allow-list)

  Build / lint / test tooling (transitive, dev-only):
  - @babel/core 7.29.0 -> 7.29.7 (via @vitejs/plugin-react and
    eslint-plugin-react-hooks)
  - vite 7.3.2 -> 7.3.5
  - markdown-it 14.1.1 -> 14.2.0 (via @tiptap/extension-link
    -> @tiptap/pm -> prosemirror-markdown; Bambuddy never calls
    markdown-it.render directly)
  - js-yaml 4.1.1 -> 4.2.0 (via eslint)
  - form-data 4.0.5 -> 4.0.6 (via jsdom)
  - ws 8.20.1 -> 8.21.0 (via jsdom)
2026-06-17 08:18:33 +02:00
maziggy a15a40449d Updated README 2026-06-16 12:06:37 +02:00
maziggy 2940fbdcf7 feat(auth): admin-configurable session lifetime ceiling (#1706)
The 24h session cap from the M-2 audit finding was hard-coded, so the
  "Remember Me" checkbox could only control storage location, never
  duration. Add session_max_hours setting (default 24, max 720) honoured
  at all four token-issuance sites: plain login, 2FA TOTP/email, 2FA
  backup, OIDC.

  - backend/app/core/auth.py: SESSION_MAX_HOURS_HARD_CEILING + resolver
    that clamps to [1h, 720h] and falls back to 24h on missing/blank/
    unparseable. DB errors propagate — the login transaction must abort
    on a broken DB rather than silently extend or shrink the lifetime.
  - backend/app/api/routes/auth.py, mfa.py: all four sites read the
    resolved value instead of ACCESS_TOKEN_EXPIRE_MINUTES directly.
  - backend/app/schemas/settings.py, routes/settings.py: schema field
    with ge=1 le=720 + int coercion in _build_settings_response.
  - frontend/src/pages/SettingsPage.tsx: half-width card at top of
    Settings -> Users left column with 24h/7d/30d presets, custom input,
    and a yellow warning when value > 24h.
  - frontend/src/i18n/locales/*.ts: 8 new keys per locale, real
    translations in all 11 (en/de/es/fr/it/ja/ko/pt-BR/tr/zh-CN/zh-TW).
  - backend/tests/integration/test_session_policy.py: 15 tests across
    resolver clamping, login JWT exp end-to-end, settings API round-trip.

  Already-issued tokens keep their original expiry; the new setting only
  affects future logins.
2026-06-16 12:00:27 +02:00
maziggy b97f25d819 fix(spoolbuddy): inventory search matches spool ID + storage location (#1738)
The SpoolBuddy inventory page reimplemented its filter inline and only
  matched material/subtype/brand/color_name/note, while Bambuddy's main
  inventory uses the shared filterSpoolsByQuery helper which also matches
  spool ID, slicer_filament_name, and storage_location. Delegate to the
  shared helper so both pages stay in lockstep.

  - frontend/src/pages/spoolbuddy/SpoolBuddyInventoryPage.tsx: replace
    inline filter with filterSpoolsByQuery
  - frontend/src/__tests__/pages/SpoolBuddyInventorySearch.test.ts: lock
    in ID / partial ID / pre-fix fields / parity-gain fields
2026-06-16 11:17:28 +02:00
maziggy e198338b29 Hosekeeping 2026-06-16 10:49:01 +02:00
maziggy eaf641ce31 . 2026-06-16 10:46:58 +02:00
maziggy eb5154f61a feat(queue): tabbed page, batch grouping, multi-drag, Gantt timeline
Restructures the queue page around three tabs (Queue / History / Timeline)
  and adds first-class batch grouping plus a real time-based timeline.

  Queue tab
  - Layout toggle: Sort by Position (flat list) or Group by Printer (per-
    printer section cards with aggregate count / time / weight headers).
  - Batch grouping: pending items sharing a batch_id render as a single
    collapsible row with aggregate stats; children draggable within the
    batch only. Per-batch collapse state in localStorage.
  - Multi-drag: dragging any selected row moves all selected items as a
    contiguous block via DragOverlay (+N ghost).
  - Selection bar gains a Group as batch action when 2+ ungrouped items
    are selected. Ungroup lives on the batch parent row.

  History tab
  - Two-line rich rows: filament color swatch + weight + type, user
    attribution, inline error message on failed / skipped rows.
  - Responsive 1 / 2 / 3 column grid so a long history uses available
    width instead of stretching one row per line.
  - Batch siblings group into a collapsible parent with status-rollup
    chips (3 OK / 1 failed / etc).
  - Thumbnail hover preview shows the full image at 192x192 next to the
    small thumb.

  Timeline tab
  - Replaces the hourly-list view with a Gantt swimlane: one row per
    printer (plus per target_model and unassigned), horizontal hour
    axis, jobs as bars positioned by start time and sized by duration.
  - Live NOW marker.
  - Only committed schedules are rendered: currently printing items,
    pending items with scheduled_time, and pending ASAP behind an active
    print. Staged (manual_start), waiting (waiting_reason), and ASAP
    jobs on idle printers are filtered out.
  - 24h rolling window with 12h step controls.
  - Per-bar tooltip with start, end, progress, batch name.

  Backend
  - POST /queue/batches creates a batch, optionally assigning existing
    pending item_ids (manual grouping) or returning an empty batch the
    client can attach to subsequent /queue/ POSTs.
  - POST /queue/batches/{id}/ungroup clears batch_id from all members
    (skipping items the caller does not own) and deletes the batch row
    when no members remain.
  - POST /queue/ accepts an optional batch_id and validates that the
    batch exists, is active, and the caller may modify it. The existing
    quantity > 1 auto-batch path still fires when no batch_id is sent.

  PrintModal
  - When N plates from one source are queued in a single submission
    (model assignment or single printer), the modal pre-creates a batch
    and passes its id to each addToQueue call so multi-plate jobs land
    grouped automatically. Falls back to ungrouped items if the batch
    pre-create fails.
2026-06-16 10:45:30 +02:00
maziggy 2d9f87354e Updated CHANGELOG 2026-06-16 08:42:03 +02:00
maziggy 804fe470fa fix(auth/ui): sidebar accepts granular *_read tiers for archives/queue/files (#1755)
navPermissions in Layout.tsx gated three resources on the LEGACY *:read flag.
  Default Operators group is seeded with *_own only (and the migration map flips
  legacy → _own on existing groups), so non-admin users never held the legacy
  permission and the sidebar hid Archives / Queue / Files even though the
  underlying API accepted their requests. Reporter only spotted Files; same bug
  shape applied to Archives and Queue.

  Fix: navPermissions accepts Permission | Permission[]; the three affected
  resources list all three tiers. isHidden checks .some(hasPermission) for
  arrays. Permission type extended with the matching *_own / *_all variants —
  backend already shipped them, the TS type just didn't declare them.
2026-06-16 07:44:22 +02:00
maziggy ead6c37147 fix(notifications): wire on_printer_offline dispatch on disconnect edge (#1752)
The provider toggle, schema, template, and NotificationService.on_printer_offline
  all shipped, but no caller invoked the dispatcher — the offline event was an
  orphan toggle. Edge detection in on_printer_status_change now schedules a
  debounced (60s) background task on the connected→disconnected transition;
  reconnect before the window elapses cancels it. Covers both upstream paths
  (smart-plug power-off via mark_printer_offline, and MQTT staleness via
  check_staleness), both of which already route through the status callback.
  The "back online" channel is the existing print-failure notification on
  firmware FAILED report — no symmetric on_printer_online needed.
2026-06-16 07:34:14 +02:00
maziggy 5fdea009fc fix(auth): preserve original URL across login + OIDC round-trip (#1750)
ProtectedRoute and PermissionRoute now pass the requested location as
  router state when redirecting to /login. LoginPage stashes it in
  sessionStorage before the OIDC provider redirect (since window.location
  kills React state) and consumes it on all three post-login navigations
  (credentials, 2FA, OIDC token exchange). Targets are sanitized to
  same-origin internal paths only — protocol-relative and /login itself
  are rejected to prevent open-redirect.

  QR labels (https://host/inventory?spool=N) now land on the scanned
  spool instead of the printer page after authentik / any OIDC SSO login.
2026-06-15 11:46:32 +02:00
maziggy b171980688 fix(archives): backfill NULL created_at + tolerate NULL in response (#1732)
Older print_archives rows (and rows that landed via the SQLite ↔ Postgres
  cross-DB restore path) can have created_at = NULL because the column was
  originally created without a DEFAULT clause — server_default=func.now()
  only fires at table creation, not for existing rows or raw cross-DB
  inserts. The list_archives response model required a datetime, so a
  single NULL row 500'd the whole endpoint via Pydantic ResponseValidationError.

  - Boot-time backfill: COALESCE(completed_at, started_at, now()) for
    any row where created_at IS NULL. Dialect-branched (SQLite datetime('now')
    vs Postgres NOW()).
  - Schema: created_at is now Optional on ArchiveDuplicate, ArchiveResponse,
    and ArchiveSlim so a future NULL-leaking path doesn't break the list
    endpoint again.
2026-06-14 12:21:57 +02:00
maziggy 7b43c545e8 Updated README 2026-06-14 11:27:40 +02:00
maziggy 3b6f6cdb57 Updated README 2026-06-14 11:27:32 +02:00
maziggy ec9e5eff61 chore(tests): silence bandit B104 on redaction-sentinel asserts
The two "0.0.0.0" comparisons in test_support_helpers verify the
  support-bundle net.info[*].ip redaction sentinel (mirrors the
  support.py:1193 annotation), not a socket bind. Annotate inline.
2026-06-14 10:58:02 +02:00
maziggy 428f3f2db1 Resolved conflicts 2026-06-14 10:52:41 +02:00
maziggy 68c06a76c4 chore(support): silence bandit B104 on net.info ip redaction
The "0.0.0.0" written into the support bundle is a JSON sentinel that
  scrubs the printer's local IP plus the gateway/peers it sees — not a
  socket bind address. Annotate inline so bandit stops flagging it.
2026-06-14 10:23:02 +02:00
maziggy f84756f8d4 chore(support): silence bandit B104 on net.info ip redaction
The "0.0.0.0" written into the support bundle is a JSON sentinel that
  scrubs the printer's local IP plus the gateway/peers it sees — not a
  socket bind address. Annotate inline so bandit stops flagging it.
2026-06-14 10:22:21 +02:00
maziggy f8d6d8fd69 Bumped version 2026-06-14 10:06:33 +02:00
maziggy 2cf6f29503 fix(vp): Send All enqueues one item per plate; archive delete cascades to queue
VP queue-mode multi-plate Send All
  ==========================================

  BambuStudio / OrcaSlicer "Send All" of a multi-plate project uploads ONE
  3MF containing every plate (one FTP STOR, single filename) — slice_info.config
  inside the file lists N <plate> blocks with their own index metadata and
  their own Metadata/plate_N.gcode payload. Pre-#1733 the VP queue path
  called _extract_plate_id which returned only the FIRST plate index, and
  _add_to_print_queue built exactly one PrintQueueItem from it. Plates 2..N
  silently dropped on the floor. From the user's perspective: Send All of a
  3-plate project produced 1 queue item, indistinguishable from a regular
  single-plate Send, with no log line to explain the discrepancy.

  The wire was confirmed against the live H2D-1 Proxy VP: the same file
  ships whether the user clicked Send or Send All; the only intent signal
  is the count of <plate> blocks inside slice_info.config.

  Fix: replaced _extract_plate_id (-> int | None) with _extract_plate_ids
  (-> list[int]). The list contains every <plate> block's index in order;
  falls back to [1] when slice_info.config is missing / unparseable so the
  single-plate case is preserved. _add_to_print_queue now loops over the
  list and creates one PrintQueueItem per plate, with:

    - plate-specific position = MAX(position) + iteration_number, so the
      items inherit consecutive positions and the slicer's plate order
      becomes the queue execution order.
    - per-plate required_filament_types / filament_overrides via
      extract_filament_requirements(file_path, plate_id) — the plate-aware
      filter shipped with #1697 — so the scheduler's per-printer "Any X"
      matching dispatches each plate onto a printer with the right
      colours loaded for THAT plate, not for plate 1's filament set.
    - shared archive_id across all plates (one upload = one archive row).
    - the VP's auto_dispatch + manual_start posture inherited unchanged.

  Net behaviour: single-plate Send hits the loop once → exactly today's
  result (one queue item, plate_id from the slicer, one archive). Multi-
  plate Send All of a 3-plate file → 3 queue items, plate_id 1/2/3,
  consecutive positions, all referencing the same backing archive.

  Archive delete cascades to queue rows
  =============================================

  Previously the soft-delete path (the default the trash-can button uses)
  called _cancel_pending_queue_items which only flipped queue rows with
  status='pending' to status='cancelled' while leaving every other status
  alone AND leaving every row in the DB. The Send All multi-plate work
  above made this much more visible: deleting an archive backed by N
  queue items now had to clean up N rows, and what users saw instead was
  N "cancelled" rows lingering in the queue history.

  Backend:
    - Replaced _cancel_pending_queue_items with _delete_related_queue_items
      (db, archive_id) -> int. DELETEs every queue row where
      archive_id = X regardless of status. Matches what the hard-delete
      path already did via the ON DELETE CASCADE FK on
      print_queue.archive_id — both paths now produce the same end state.
    - Print history lives in PrintLogEntry (FK ON DELETE SET NULL) and is
      untouched; Quick Stats / accuracy bands are preserved across both
      delete paths.
    - 409 guard on archives.py::delete_archive when any related queue
      item is currently status='printing'. Both soft and hard delete are
      gated; deleting the archive while a print is live would strip the
      dispatcher's metadata trail (filament / plate / ams_mapping) out
      from under the running print.
    - New GET /archives/{id}/delete-impact endpoint returns
      {related_queue_items: N, currently_printing: M}. Cheap, single
      endpoint, deliberately NOT folded into the archive list response
      so the much larger list endpoint isn't forced to run the same
      query per row.

  Frontend:
    - ArchivesPage delete-confirm modal queries the new endpoint when the
      modal opens (useQuery with enabled: showDeleteConfirm) and renders
      an amber "N queue items linked to this archive will also be removed"
      line when total > 0 AND printing = 0, OR a red "Cannot delete —
      M queue items are currently printing" line when printing > 0
      (confirm button disabled in that case so the user can't bonk the
      409 on submit).
    - ConfirmModal gained an optional confirmDisabled?: boolean prop —
      isLoading was the only disable knob before; this adds the external-
      precondition path.
    - 2 new i18n keys (deleteQueueItemsWarning, deleteBlockedByPrinting)
      translated across all 11 locales per feedback_translate_dont_fallback —
      no English fallbacks.

  No DB migration — the CASCADE FK was already in place; only the helper's
  semantics changed.
2026-06-13 15:58:57 +02:00
maziggy 5da5bc0aaf Updated CHANGELOG 2026-06-13 14:22:36 +02:00
maziggy b8a3e7c2c9 fix(print-log): render one swatch per color for multi-color filament rows (#1731 part 1)
The per-archive Print Log table cell at ArchivesPage.tsx:3882 rendered
  filament_color as a single swatch with
  `backgroundColor: entry.filament_color.startsWith('#') ? ... : undefined`.

  For multi-color prints, the backend writes filament_color as a comma-joined
  string ("#FFFFFF,#000000,#FF0000"). The whole string trivially passed the
  startsWith('#') check but isn't a valid CSS color — the browser silently
  drops the declaration and the swatch falls back to its black/20% border,
  which on the dark theme reads as a barely-visible grey dot. Reporter's
  screenshots showed "PLA" text with no visible swatch at all. DB column
  was correct; render dropped the colors.

  The Archive Card view at ArchivesPage.tsx:1072-1083 and :2114-2125 already
  splits on comma and renders one swatch per color — only the Print Log
  table cell had been missed when multi-color support landed elsewhere.

  Fix mirrors the card pattern: wrap swatches in a flex container, split
  on comma, trim, render one w-3 h-3 swatch per color with
  backgroundColor and title={trimmed}. Single-color prints render one
  swatch (no behaviour change). Empty / non-hex entries fall through to
  no backgroundColor rather than poisoning the CSS for siblings.

  Does NOT cover the reporter's second symptom — new multi-color prints
  missing from filament usage history. That's usage_tracker._track_from_3mf
  and the slot-to-tray mapping chain; needs a support bundle (PRINT START
  + PRINT COMPLETE [UsageTracker] log lines + captured ams_mapping) before
  shape can be confirmed. Tracking as #1731 part 2.
2026-06-13 14:10:21 +02:00
maziggy be7e85344c fix(finish-photo): drive capture from stg_cur=22, drop dispatch force-on (#1721)
capture_finish_photo (default-on) was forcing the timelapse MQTT field to
  true on every print, even when the user explicitly unchecked Timelapse in
  the slicer send dialog. On profiles with Timelapse Type = Smooth, that
  flipped the printer's timelapse_record_flag and un-gated the per-layer
  M622 J1 wipe blocks the slicer had baked in — toolhead parked off the
  part every layer, on prints the user opted out of recording.

  Root cause: #1397 implemented the finish-photo feature as a side channel
  of "force the printer into timelapse-recording mode at dispatch" so the
  last-frame extractor had a video to pull from. That conflated recording a
  timelapse with snapping a finish photo, and the per-layer side effects
  were decided at slice time by the user's timelapse_type, which Bambuddy
  has no visibility into post-slice.

  Fix: replace the force-on with a clean MQTT-state-driven trigger.

    bambu_mqtt.py fires a new on_finish_photo_moment callback when
    stg_cur transitions INTO 22 ("Filament unloading") while
    _was_running AND end-of-print gate matches (progress >= 99 OR
    layer_num >= total_layers OR remaining_time <= 0). The gate
    disambiguates from mid-print color swaps (which also transit
    stage 22 but at progress < 99). FINISH-state fallback in the same
    handler fires the callback at the existing transition if stage 22
    never arrived (cancel, external-spool-only, HMS halt, firmware
    variants).

    main.py registers on_finish_photo_moment as a top-level handler.
    It pre-captures one camera frame at the trigger edge (external cam
    → buffered RTSP → fresh RTSP via capture_camera_frame_bytes) and
    caches the JPEG bytes in _stage22_finish_frames[printer_id].
    _background_finish_photo consumes the cached bytes before its
    existing live-grab chain, so the saved photo has the better
    framing (toolhead parked, before bed drop) without restructuring
    the archive-resolution / fallback / notification wiring.

    When a timelapse IS actively recording (user explicitly opted in),
    pre-capture is skipped — _capture_finish_photo_from_timelapse
    still extracts the last frame, which is still the best framing
    and now has no force-on side effects because the user wanted the
    video.

  Removed: resolve_effective_timelapse, _resolve_effective_timelapse
  wrapper, both background_dispatch call sites, the print_scheduler call
  site, the archive.bambuddy_forced_timelapse write, _cleanup_forced_timelapse
  (~75 lines including the FTP-DELE walk across /timelapse, /timelapse/video,
  /record, /recording) and its call site. All paths now read
  bool(item.timelapse) / bool(job.options.get("timelapse", False)) directly.
  The archive.bambuddy_forced_timelapse DB column stays defined (default
  False) for back-compat with existing rows — no consumer reads it anymore.
2026-06-13 13:28:58 +02:00
maziggy 168d3cb05c fix(ams): filter Configure AMS Slot profile list by printer model (#1623)
The Filament Profile picker in the Configure AMS Slot modal listed
  profiles for every printer the user had ever imported / cloud-synced.
  On H2D the reporter saw local "Custom" PETG/PLA for A1 mini and P1S
  alongside Bambu Cloud and Orca Cloud profiles named "X1C eSUN PETG-
  Basic Filament" - none of them usable on the slot they were configuring.

  Three filter gaps in the same picker:

  - Local "Custom" imported profiles were unconditionally listed. Now
    parse their compatible_printers JSON and run presetCompatibility()
    against the slot's full slicer preset name ("Bambu Lab H2D 0.4
    nozzle") derived from the printer-model registry + slot nozzle.
    Hide on 'mismatch'; 'match' and 'unknown' keep showing (back-compat
    for hand-edited imports without compatible_printers).

  - Cloud presets with the "@Bambu Lab <long-name>" suffix form (user-
    renamed Bambu Cloud presets, most Orca Cloud profiles) slipped
    through the existing "@BBL <code>" matcher. extractPresetModel now
    handles both, with case-insensitive registry reverse-lookup so
    "A1 mini" vs "A1 Mini" capitalisation drift doesn't hide A1 Mini
    profiles (#1649 alias match preserved).

  - Cloud presets with the model in the BODY of the name and no @ suffix
    ("X1C eSUN PETG-Basic Filament") returned null from the extractor.
    Added a body-text scan against every known model token from the
    registry, long-first sort so "A1 Mini" / "X1 Carbon" / "H2D Pro"
    aren't eaten by their shorter siblings, word-boundary regex so
    "PA1" doesn't false-match "A1".

  Fail-open posture preserved: registry not loaded or printerModel empty
  no-ops every filter; saved preset bypass keeps the active selection
  visible; built-in filaments stay unfiltered (generic fallback); free-
  form names with no recognisable token still show.
2026-06-13 10:38:13 +02:00
maziggy 43adb6f964 Security hardening (security #2) 2026-06-13 09:35:49 +02:00
maziggy 8a63fcbf57 fix(vp): apply tray_exist_bits empty-slot cleanup to slicer-facing cache (#1726)
VP bridges bound to a target printer (Proxy mode, Queue mode with
  specific target) forwarded the printer's raw AMS push_status to the
  slicer untouched. bambu_mqtt.py::_handle_ams_data applies a
  tray_exist_bits-driven cleanup to Bambuddy's internal state
  (promote empty slots to state=9, wipe stale tray_type / tray_color /
  tray_info_idx / tag_uid / tray_uuid / remain) so the AMS card renders
  empty slots as Empty, but the VP bridge cache never ran the same
  cleanup. Net result on real hardware: a printer with 3 loaded
  filaments and several previously-loaded-now-empty slots had Bambuddy's
  AMS card render those slots correctly as Empty, but BambuStudio after
  Sync painted them as phantom loaded filaments with stale color and
  material from before the slot went empty.

  Root cause: two consumers of the same payload, only one wired to the
  cleanup. _handle_ams_data ran it on every push; mqtt_bridge.py::
  _on_printer_raw merged the ams blob via _merge_ams_dict but copied
  tray_exist_bits through as an opaque scalar without acting on it.

  Fix: factored the bit-clear logic out of _handle_ams_data into a
  module-level helper apply_tray_exist_bits(units, tray_exist_bits_str,
  *, power_on_flag, log_label). Internal path replaced with a single
  call. Bridge calls it after _merge_ams_dict on the merged ams dict,
  before the merged state is stored as the 1 Hz cached-as-base source.

  Shared shutdown guard kept on both sides: all-zero bits +
  power_on_flag=False is the printer-off pattern (#765, would
  propagate phantom empties on every reconnect); nonzero bits +
  power-off is valid idle-printer state (#1365, X1C between prints)
  and still applies. AMS-HT units (id >= 128) skipped on both sides.

  Tests: new TestApplyTrayExistBitsHelper (10 cases) pins the helper
  contract directly. 3 new bridge regression tests reproduce the
  #1726 wire shape, the shutdown guard, and the AMS-HT skip on the
  cached slicer-facing state. Existing internal-state tests for the
  bit-clear logic (covers state=9 promotion, loaded-slot preserve,
  genuine-removal-with-power-on) continue to pass against the
  refactored path.

  One pre-existing bridge fixture had an inconsistent tray_exist_bits
  ('3' for 2 AMS units each with slot 0 loaded — bit 4 missing). The
  shared cleanup exposed it; corrected to '11' (bits 0 + 4) to match
  real-printer wire shape.

  Reported by @needo37 with full code-level analysis including the
  suggested fix shape and the BAMBUDDY_VP_DUMP_WIRE diagnostic to
  verify on a live system.
2026-06-13 08:42:59 +02:00
MartinNYHC ea75788abc Merge pull request #1728 from maziggy/dependabot/npm_and_yarn/frontend/npm_and_yarn-f148cc1241
chore(deps-dev): bump esbuild from 0.27.3 to 0.27.7 in /frontend in the npm_and_yarn group across 1 directory
2026-06-13 08:14:15 +02:00
MartinNYHC 19aa2948a7 Merge pull request #1728 from maziggy/dependabot/npm_and_yarn/frontend/npm_and_yarn-f148cc1241
chore(deps-dev): bump esbuild from 0.27.3 to 0.27.7 in /frontend in the npm_and_yarn group across 1 directory
2026-06-13 08:13:36 +02:00