mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-02 12:15:36 +02:00
4ff4bffe9f9fe8684f3d1744eeba5278f4df2bb7
391
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
f87b5bb3b8 |
feat(diagnostic, archives): install step 4 — proactive check + reactive banner
Two complementary surfaces for the most-missed install step ("Store sent
files on external storage"):
1. Connection diagnostic check (printer-side variant)
- Reads state.store_to_sdcard, parsed from MQTT home_flag bit 11.
- Pass / fail / skip; instant, no I/O.
- Catches the newer-firmware variant where the toggle moved onto the
printer itself (P2S 01.02 / Studio 2.6+).
An FTP upload-and-verify probe was tried first and rejected. /cache
is always writable from Bambuddy regardless of the slicer setting;
only BambuStudio's own behaviour changes when the toggle flips.
Empirically confirmed against X1C + H2D with the slicer option
toggled off: probe still succeeded, home_flag bit 11 stayed True.
2. Archives-page banner (slicer-side variant)
- The slicer-side toggle is invisible to the printer — older
BambuStudio doesn't push the change to the printer. The diagnostic
can't see it.
- Symptom is deterministic: archiver creates rows with
extra_data.no_3mf_available=True (main.py:2770) when it can't pull
the 3MF from /cache after a slicer-initiated print.
- New endpoint GET /archives/no-3mf-warning returns whether any
archive in the last 30 days has the flag (excluding soft-deleted).
- Amber dismissible banner at the top of /archives; one-shot
localStorage dismissal (matches Layout.tsx update-banner pattern,
but persistent across sessions).
- React-Query disabled after dismissal so the endpoint isn't polled
once the user has been told.
|
||
|
|
bebdb38e41 |
feat(print-log): per-row classification editor + fix silent-drop in GET
(#1687 part 4, reported by @IndividualGhost1905) Reporter clarified after part 1 shipped that point 2 wasn't about archive `tags` (which describe the model — home decor, toys), but about failure-cause classification on the *log* row itself: spaghetti, jam, bed-adhesion, etc. Different surface, different lifetime. The data field he wanted already existed. PrintLogEntry.failure_reason is a String(100); the Failure Analysis widget already groups by it; the Archive Edit modal already mirrors archive.failure_reason into the most recent log entry (archives.py:1421, shipped with #1444). The only gaps were: 1. The GET endpoint silently dropped failure_reason (and archive_id and created_by_id) from PrintLogEntrySchema construction even when set in the DB — so the Print Log table couldn't render what the Failure Analysis widget grouped by. Fixed independently of the editor; regression test added. 2. Orphan log entries (no archive — dispatch errors, aborts before archive creation, manual entries) had no edit path at all because the Archive Edit modal cannot reach them. The new endpoint is the only way to classify those rows. Changes: - Backend: new PATCH /print-log/{entry_id} taking {failure_reason, status}, gated on require_ownership_permission( ARCHIVES_UPDATE_ALL, ARCHIVES_UPDATE_OWN) — same ownership shape as the per-row DELETE. Validates against the same 11-key failure vocabulary and 5-key status set the Archive Edit modal uses; unknown values return 400 rather than getting stored as raw text (the i18n layer maps the value back through the vocabulary, unrecognised values would render as literal strings). Empty-string failure_reason stores back as NULL so the column's nullable=True intent is preserved end-to-end. GET endpoint now surfaces failure_reason, archive_id, created_by_id. - Frontend: FAILURE_REASON_KEYS moved to an export from EditArchiveModal.tsx so the new editor reuses the exact same vocabulary — backend and frontend stay in lockstep. Pencil icon beside the existing trash icon on every Print Log row, opens a compact two-field modal (status + failure reason). Save invalidates print-log and archives-stats query keys so the Failure Analysis widget reflects the re-classification on the same response cycle. Failure reason rendered as a sub-label under the status badge, matching PrintLogTable.tsx's convention. - i18n: 10 new keys (editEntryTitle, editEntryDescription, entryUpdated, entryUpdateFailed, archives.permission.noEdit, plus a 5-key statuses block) translated across all 11 locales. No English fallbacks. - Wiki: features/print-log.md gains per-row actions section, updated permissions table, PATCH/single-DELETE endpoint docs. |
||
|
|
85fbd7fc35 |
fix(queue): persist "Print Anyway" so scheduler stops re-flagging it
When the user clicked Print Anyway on a filament-deficit warning, the
acknowledgement was one-shot. The route cleared manual_start and
filament_short, then the next scheduler tick re-ran
compute_deficit_for_queue_item against identical spool state, found
the same deficit, and re-set both flags. The item bounced between
"user said anyway" and "scheduler re-blocked" — every Play click
returned 409, every confirm got rolled back on the next tick.
Add a persistent acknowledgement flag on the queue item:
- New column `skip_filament_check` on print_queue. SQLite + Postgres
migration branched on is_sqlite() so Postgres doesn't reject
DEFAULT 0 on BOOLEAN.
- PrintQueueItemCreate + PrintQueueItemResponse schemas + the
TypeScript types carry the field.
- POST /print-queue/{id}/start with skip_filament_check=true now
ALSO sets item.skip_filament_check = True (not just clearing
manual_start / filament_short).
- PrintScheduler._block_on_filament_deficit short-circuits to
False — no compute, no flag-setting, no notification — when
item.skip_filament_check is True. We trust the operator's
decision and stop fighting them.
- PrintModal at queue-creation time threads
skip_filament_check=true into the create payload when the user
clicks Print Anyway on the frontend deficit warning, so a print
that was warned-then-acknowledged at add-to-queue time goes in
pre-acknowledged — scheduler never blocks it on first tick.
Flag is not auto-cleared on spool swap by design: if remaining is
now sufficient, the check returns no deficit anyway, so the flag
is moot. Auto-clearing would add lifecycle complexity without
changing behaviour.
AMS Backup awareness (the other half of the discussion) intentionally
NOT included — verified the H2D's bit-26 of print.cfg toggles with
the printer-side AMS Backup setting, but the X1C's cfg has a
different shape entirely and verifying every model family isn't
realistic. Silently under-warning would be worse than always
per-slot. The check stays single-slot for now.
|
||
|
|
e9b6fefe95 |
fix(auth): redirect to /login when JWT expires mid-session (#1698)
When the JWT expired on an open tab, the next API request hit a 401 with
"Token has expired"; client.ts cleared the token from storage but
AuthContext.user stayed populated from the original mount. ProtectedRoute
only redirects when user === null, so the protected tree kept rendering
and every subsequent request silently failed with no Authorization
header — the UI looked like every list was empty until a manual refresh
remounted AuthProvider.
The 3 other setAuthToken(null) sites live inside AuthContext itself and
already pair with setUser(null), so only the client.ts cross-module
site needed a React-tree signal.
- client.ts: after setAuthToken(null) on a token-invalidating 401,
window.dispatchEvent(new CustomEvent('auth:expired')). Guarded on
`typeof window !== 'undefined'` for SSR / test safety. Generic
"Authentication required" 401s still don't clear the token or fire
the event — treated as transient timing issues per the pre-existing
comment at client.ts:155.
- AuthContext.tsx: mount useEffect adds a window listener that calls
setUser(null) under the mountedRef guard; cleanup removes the
listener so unmount → remount doesn't double-bind.
Mirrors the patch the reporter shipped on their fork (deec96d1).
|
||
|
|
40729da013 |
feat(print-log): per-row delete on Print Log page (#1687 part 1)
Reporter noted the existing "Also remove this print from Quick Stats"
toggle at archive delete is one-shot: if you kept stats then, there was
no later way to drop the row; and rows without a backing archive
(errors, aborts, manual entries) had no delete affordance at all.
Backend: DELETE /print-log/{entry_id} mirrors delete_archive's
ownership flow via require_ownership_permission(ARCHIVES_DELETE_ALL,
ARCHIVES_DELETE_OWN). Owners drop their own rows; admins drop any row;
missing IDs return 404 rather than 200-silently. /archives/stats
aggregates over PrintLogEntry, so the filament / time / cost / count
contribution drops out of Quick Stats in the same response cycle. The
linked archive (if any) is untouched - the log row is a sibling, not a
child.
Frontend: trash icon next to the filament cell on every row, gated on
the same permission shape as the archive trash. Confirm modal -> row
gone. Mutation invalidates both print-log and archives-stats query
keys so the totals re-render without a manual refresh.
#1687 also asks for per-row tagging (already covered by
EditArchiveModal's tags field) and per-row filament-usage-history
edits (deferred - "restore deducted grams" is only consistent for the
most recent usage row per spool; needs a separate design call).
|
||
|
|
68877c639e |
feat(settings): split API slicer + Open-in-Slicer preferences (#1329)
Reporter wanted to slice via the Bambu Studio sidecar but open files
locally in OrcaSlicer. preferred_slicer drove both the in-app
SliceModal sidecar selection AND the desktop "Open in Slicer" URI
handoff, so picking one forced the other.
New open_in_slicer setting (str | None) drives only the desktop URI;
null inherits from preferred_slicer so existing installs behave
identically. Storage in the existing app_settings key/value table;
GET normalises the "None" string back to null mirroring the
default_printer_id convention.
Frontend: Settings -> Slicer card adds a second dropdown ("Open in
Slicer" with "Same as API slicer" / Bambu Studio / OrcaSlicer);
ArchivesPage, MakerworldPage, ModelViewerModal switch desktop-URI
call sites to open_in_slicer ?? preferred_slicer. MakerworldPage's
"Slice in {{slicer}}" label additionally branches on useSlicerApi
so the label matches what the button actually dispatches.
|
||
|
|
432080d500 |
feat(queue): show build plate type on queue items + print modal (#1281)
Reporter on a multi-printer farm with 40+-plate runs needed to walk to
the printer with the right physical plate, but the queue and the
scheduling modal didn't surface curr_bed_type the way the archive card
already did.
New utils/threemf_tools.extract_bed_type_from_3mf helper (per-plate) so
both the queue API and the /plates endpoint can return per-plate values.
PrintQueueItemResponse.bed_type populated from archive.bed_type /
library_file.file_metadata['bed_type'] as the default, then overridden
per-plate via the helper when item.plate_id is set. /archives/{id}/plates
(and library equivalent) include bed_type in each plate object.
Per-plate accuracy matters because archive.bed_type is captured at
ingest as only the first plate's value (services/archive.py:235) -
a 40-plate 3MF mixing PEI + Engineering returns PEI at the archive
level for every plate. The helper re-reads the 3MF and returns the
truth.
Frontend: queue card meta row + PlateSelector per-plate row + PrintModal
header all render bed icon + canonical label via the existing
getBedTypeInfo() helper, same as the archive card.
|
||
|
|
66c09dff2d | feat(inventory): CSV import/export for the inventory page (#1576) (#1659) | ||
|
|
2c2725cb53 |
fix(print): expose nozzle_offset_cali toggle for dual-nozzle printers (#1682)
Bambuddy's project_file MQTT payload hardcoded "nozzle_offset_cali": 2 (skip), giving users on H2D / H2D Pro / H2C / X2D no way to control the same toggle BambuStudio exposes. Critical for diamond-nozzle setups that must keep the calibration off. start_print() now takes a nozzle_offset_cali kwarg; the value is encoded as 1 (run) or 2 (skip) and gated on is_dual_nozzle so single-nozzle machines always send 2 even if a stale flag arrives. The kwarg threads through printer_manager, both background_dispatch sites, and print_scheduler so every dispatch path respects the per-item setting. print_queue gains a nozzle_offset_cali column (DEFAULT TRUE, is_sqlite() branch for Postgres BOOLEAN). Settings default key default_nozzle_offset_cali defaults to TRUE to match BambuStudio. Schemas updated across print_queue, library FilePrintRequest, archive ReprintRequest, settings. PrintModal renders the new toggle only when the selected printer is dual- nozzle (printer-mode: nozzle_count===2; model-mode: DUAL_NOZZLE_MODELS). SettingsPage default-print-options row + QueuePage bulk-edit tri-state both hide unless any registered printer is dual-nozzle. Labels reuse the existing settings.default* keys so the only new i18n strings are settings.defaultNozzleOffsetCali / Desc and queue.bulkEdit.nozzleOffsetCali - real translations in all 11 locales. |
||
|
|
4851f54595 |
feat(file-manager): split "All Files" into internal-only + External views (#1621)
Reporter linked a NAS and the auto-imported files drowned their own Bambuddy uploads in the "All Files" sidebar listing. There was no filter to escape it — only per-folder clicks. Restore the pre-external semantics: "All Files" now lists managed-storage files only. The combined across-every-external view moves to a new sibling sidebar entry, "External", that only appears when at least one external folder is linked. Backend: GET /api/v1/library/files gains internal_only and external_only query flags. Filter is on LibraryFile.is_external. Both flags set is a 400, not a silent pick-one. Frontend: new topLevelView state on FileManagerPage (default internal); the query passes the scope only when selectedFolderId is null. Mobile selector dropdown uses __top:internal / __top:external sentinels so the same state round-trips through option values. Empty-state copy distinguishes internal-empty from external-empty. |
||
|
|
9554ebd05d |
refactor(inventory): rename /reset-usage to /reset-consumed-counter to match what it actually does (issue #1644)
The old endpoint name implied that calling it would drop weight_used to
0. In practice it only stamps weight_used_baseline = weight_used so the
Inventory page's "Total Consumed" widget (weight_used - baseline) reads
0 going forward, while remaining (label_weight - weight_used) is
preserved. Calling the endpoint via curl and seeing weight_used
unchanged in the JSON response is confusing.
New paths:
- internal: /api/v1/inventory/spools/{id}/reset-consumed-counter
/api/v1/inventory/spools/reset-consumed-counter-bulk
- spoolman: /api/v1/spoolman/inventory/spools/{id}/reset-consumed-counter
/api/v1/spoolman/inventory/spools/reset-consumed-counter-bulk
Behaviour is unchanged in both modes; internal stamps the baseline
directly, Spoolman-mode PATCHes upstream used_weight=0 and the
_map_spoolman_spool read mapping reconstructs the same "displayed
consumed = 0, remaining unchanged" Bambuddy-visible shape. Parity
between modes was already in place and is preserved.
The Spoolman-client method reset_spool_usage keeps its name because it
describes what is sent upstream to Spoolman, not what Bambuddy's
endpoint promises to callers.
Frontend:
- api.resetSpoolUsage / bulkResetSpoolUsage (and Spoolman variants)
renamed to resetSpoolConsumedCounter / bulkResetSpoolConsumedCounter.
- Button labels: "Reset usage to 0" -> "Reset counter" / "Reset all
counters" (short, unambiguous); tooltips and confirm-modal bodies
still spell out the full semantics.
|
||
|
|
18d534c945 |
feat(orca-cloud): integrate Orca Cloud profile sync across UI, slicer and SpoolBuddy
Reads, lists, and slices with profiles from a user's Orca Cloud account (OrcaSlicer 2.4.0-alpha's Supabase-backed sync) alongside the existing Bambu Cloud integration. Four sign-in providers (Google / Apple / GitHub / email+password); password defaults. Paste-flow PKCE because Orca's Supabase project only allowlists localhost redirect_to — open feature request at OrcaSlicer/OrcaSlicer#14028. Surfaces: - Profiles tab: new "Orca Cloud" tab next to "Bambu Cloud" with the same rich layout (search + 5 filter dropdowns + 3-column grouped grid + read-only detail modal) - SliceModal: 4-tier preset picker (orca_cloud > local > bambu cloud > standard); separate status banner per cloud; metadata-aware pre-pick scores Orca filaments above local (Orca's sync_pull returns full content inline so filament_type / filament_colour come for free, no per-setting fetch rate-limit dance) - ConfigureAmsSlotModal: orca_cloud as a new preset source (prefixed orca_<UUID> to match local_/builtin_); generic Bambu filament-ID derivation from parsed material (printer firmware can't grok Orca UUIDs); slot mapping persists preset_source='orca_cloud' - SpoolForm / SpoolBuddyWriteTagPage: Orca filaments merge into the cloud preset list via Promise.allSettled (OrcaProfileMeta is structurally identical to SlicerSetting) Backend: - services/orca_cloud.py: OrcaCloudService with PKCE / token exchange / single-use refresh rotation / get_user_info / list_profiles via the bare /sync/pull bootstrap path - routes/orca_cloud.py: 7 endpoints (auth/start, auth/finish, auth/password, status, logout, profiles, profiles/{id}); router-level _cloud_api_key_gate + per-route cloud_caller() so API-keyed callers (SpoolBuddy kiosk) properly resolve their owner User; just-in-time refresh with atomic persist-before-API-call - routes/slicer_presets.py: _fetch_orca_cloud_presets mirrors the Bambu Cloud fetcher (status vocabulary, 5min cache, permission shortcut); _dedupe_by_name extended to 4 tiers; UnifiedPresetsResponse gains orca_cloud + orca_cloud_status - services/preset_resolver.py: PresetRef.source extended with "orca_cloud"; _resolve_orca_cloud walks list + filters - 8 columns on users table for tokens (5 persistent) + transient PKCE handshake state with 10-min TTL (3); dialect-branched DATETIME / TIMESTAMP; auth-disabled mode falls back to Settings table - orca_cloud:auth permission folded into can_access_cloud API-key scope (same trust dimension) |
||
|
|
a1cb5d5b4d |
fix(backup): interpret scheduled-backup HH:MM as local time, not UTC (#1602 follow-up)
The Scheduled Local Backups time-of-day picker was interpreted as UTC by _calculate_next_run, so a UTC+3 user had to enter 18:00 to get a 21:00 local backup. The UI labeled the field "UTC" but it was still surprising. Picker is now interpreted in the container's local timezone, resolved from the TZ env var via zoneinfo.ZoneInfo (same source the Support page's environment.timezone shows). UTC fallback when TZ is unset or unrecognised. The /local-backup/status endpoint exposes the resolved zone, and the UI renders it next to the field via a new backup.localTimeHint i18n key with real translations in all 10 non-English locales. One-time behaviour change for users who entered a UTC time as a workaround: the first scheduled cycle after upgrade will run at their local TZ offset earlier than expected. Re-enter the time as local once and it is correct from then on. No migration is shipped; migrating around a DST boundary would be ambiguous. |
||
|
|
5d6d928b3f |
fix(virtual-printer): #1429 net.info[*].ip cache leak + mode wire-value rename
#1429 (reported by @TrickShotMLG02, confirmed by @Mape6 on a flat single-LAN that rules out subnet / mDNS-reflector theories): with the physical printer off the slicer's "Send" landed in Bambuddy's archive; once the printer powered on every subsequent "Send" went straight to the printer's SD card and bypassed Bambuddy. Bundle analysis: mape6-before showed clean FTP receive + archive lines, mape6-after had zero FTP attempts to Bambuddy once the printer was online. Cause: mqtt_bridge.py::_resolve_client encoded _target_ip_uint32_le / _vp_ip_uint32_le ONLY on client-identity change and early-returned on every refresh tick when the same client object was still bound. If target_client.ip_address was empty at first bind (DB row stale, or client constructed before SSDP refresh filled it in), the encoding stayed None, the net.info[*].ip rewrite block was skipped, the cache filled with the real printer IP, sticky-key preservation kept the poisoned net value alive across every subsequent incremental push, and the slicer followed the leaked IP. Only Bambuddy-restart-with-printer-off cleared it — the workaround both reporters independently arrived at. Same shape on multi-NIC printers (X1C, H2D Pro): the rewrite only matched entries whose ip equalled _target_ip_uint32_le, so a secondary interface IP Bambuddy never saw would leak through unchanged. Bridge fix: - _resolve_client calls a new _refresh_ip_encoding() on every refresh tick, even when client identity is unchanged; self-heals once ip_address becomes valid. - _refresh_ip_encoding() sweeps the existing _latest_print_state when encoding becomes valid for the first time. Without the sweep, sticky-key preservation keeps the pre-arm poisoned cache alive forever — incremental pushes that don't include net carry the bad value forward. - _rewrite_net_info_ips() rewrites EVERY non-zero net.info[].ip entry that doesn't already equal the VP IP, not just entries matching _target_ip_uint32_le. Multi-NIC printers stop leaking secondary interfaces. Zero-IP placeholders are left alone so "active interface" detection still works. - INFO logging on encoding arm/update and on cache sweep so future bundles directly answer "did the rewrite fire?". Mode wire-value rename (#1429 follow-up, separate confusion source): - Both reporters' support bundles showed mode: immediate while the UI said "Archive"; @TrickShotMLG02 quoted: "I have no idea why it says immediate in the support-info.json file. In the webui the printer is set to archive". UI button "Archive" had always saved immediate, and "Queue" had always saved print_queue. Canonical wire values are now archive / review / queue / proxy, matching the button labels 1:1. - New normalize_vp_mode() + VP_MODE_* constants in models/virtual_printer.py; manager.py normalises on construction so a legacy row read pre-migration still dispatches correctly. - core/database.py::run_migrations rewrites existing virtual_printers and settings rows; idempotent (re-runs are no-ops); identical SQL under SQLite and Postgres. - API routes accept both legacy and canonical on input, normalise before storage. GET /settings/virtual-printer normalises on read so the frontend's mode-button highlight works for stale legacy values. - Three frontend VP components (VirtualPrinterSettings, VirtualPrinterCard, VirtualPrinterAddDialog) switched click handlers and type aliases to canonical; each got its own normalizeMode() helper so a stale-cached settings payload still highlights the right button. Two pre-existing `printer.mode === 'queue' ? 'review'` legacy mappings in VirtualPrinterCard were the source of a test failure caught mid-implementation where the new canonical 'queue' was being mis-aliased back to 'review' and hiding the auto-dispatch + force-color-match toggles. mode handler is NOT the dispatch bug: manager.py::_archive_file (the handler for archive mode) doesn't dispatch to the physical printer. The "files end up on the printer's SD card" symptom was the IP-leak from the bridge cache. The mode rename is purely clarity / support- bundle accuracy. |
||
|
|
171848a0fc |
fix(slicer-presets): #1581 SliceModal refresh + invalidate on local-profile delete/import
Two-part fix for the reporter's "removed profiles still show on the slice
menu" symptom.
Local half (real bug). LocalProfilesView's import and delete mutations
invalidated ['localPresets'] (the management view's own query) but not
['slicerPresets'] (the SliceModal's unified preset query, staleTime 60s).
A freshly-deleted preset kept rendering in the slice dropdown until that
staleTime elapsed plus a refocus/remount. Both mutations now also call
queryClient.invalidateQueries({queryKey: ['slicerPresets']}).
Cloud half (opt-in cache bypass). _fetch_cloud_presets keeps a 5-minute
per-(user, token) in-process cache (slicer_presets.py:69, balances
"users see freshly-saved presets quickly" against "busy install doesn't
hit Bambu Cloud once per modal open"). Users delete cloud presets in
Bambu Studio / Bambu Handy, not in Bambuddy, so there's no event hook
to invalidate on. Rather than shorten the TTL globally, the listing
endpoint gains an opt-in ?refresh=true query param that bypasses both
the cloud cache AND the 1-hour bundled-preset cache for that one call;
the fresh result is still written back so subsequent normal callers
keep hitting the cache.
New SliceModal "Refresh" button. Lives in the preset section header
next to the cloud-status banner. Calls getSlicerPresets({refresh: true})
and writes the fresh slots into the ['slicerPresets'] cache via
queryClient.setQueryData so the spinner stops immediately rather than
triggering a second refetch. RefreshCw icon spins while in-flight;
disabled during slice enqueue to prevent double-fire.
|
||
|
|
b7d7c82501 | fix(security): WebSocket auth gate + audit-driven hardening sweep | ||
|
|
ec51394196 |
fix(security): GHSA-r2qv-8222-hqg3 — allowlist API-key permissions (CVSS 9.9)
API-key permission gates went from a 17-entry admin denylist with the three
documented scope flags (can_read_status / can_queue / can_control_printer)
enforced only inside /api/v1/webhook/* to an explicit per-Permission
allowlist consulted by every dependency:
- core/auth.py: _APIKEY_SCOPE_BY_PERMISSION maps every non-admin
Permission to one scope flag on APIKey; unmapped = 403.
_check_apikey_permissions now takes the api_key and checks the flag.
- require_any_permission_if_auth_enabled + require_ownership_permission
were returning None for any valid key with zero scope check; both now
invoke _check_apikey_permissions and fail closed.
- Two new scope flags on api_keys: can_manage_library (LIBRARY_UPLOAD /
UPDATE_OWN / DELETE_OWN / MAKERWORLD_IMPORT) and can_manage_inventory
(INVENTORY_CREATE / UPDATE / DELETE / FORECAST_WRITE — required by
SpoolBuddy kiosks). Default TRUE, backfilled from can_queue so existing
"queue-only" keys keep working and hardened "read-only" keys do not
silently gain writes.
- CLOUD_AUTH now routed through can_access_cloud for defence-in-depth
alongside the existing _cloud_api_key_gate.
- Migration column-existence check (_api_keys_column_exists) gates the
backfill so user-edited values are never overwritten on restart.
Structural drift backstop: test_every_permission_has_a_classification fails
CI on any new Permission added without an explicit scope mapping —
prevents the denylist-shape regression that grew the prior surface.
Backend 5469 tests green; ruff clean. Frontend build green; i18n parity
green across 9 locales (5005 leaves each, +6 new keys). Wiki permissions
table + allowlist callout + upgrade notes updated.
|
||
|
|
1e734fb7c6 |
fix(stats): cancelled prints get their own bucket; gauge denominator excludes them (#1390 follow-up)
Reporter (@IndividualGhost1905) saw Total: 20 / Success: 18 / Failed: 1 and asked where the 20th print went. The Quick Stats endpoint counted status == "completed" → Successful and status == "failed" → Failed, but used a raw count(*) for Total Prints, so the four other PrintLogEntry statuses (aborted, stopped, cancelled, skipped) silently inflated the total without showing up in any breakdown row. The earlier #1390 round had committed a test locking in this exact behaviour ("uses total_prints as denominator so cancelled/stopped events count"), which was wrong: it conflated user intent with print quality. Three-bucket classification, applied across the whole stats surface and matching how the rest of the codebase already groups statuses (main.py:430, 1729; failure_analysis status filter): successful = completed failed = failed + aborted (printer-detected quality failures) cancelled = stopped + cancelled + skipped (user/queue stopped) Quick Stats endpoint returns the new cancelled_prints field; ArchiveStats.cancelled_prints defaults to 0 so older fixtures still parse. SuccessRateWidget gauge now divides by successful + failed only — a cancelled roll no longer drags the gauge down — and a Cancelled row appears in the breakdown so the missing prints don't silently vanish from Total Prints. Failure Analysis service applies the same denominator fix to both the headline failure_rate and the per-week trend, so a week with several cancellations and zero failures reads as 0% rather than a misleading "failed / total". i18n: new stats.cancelled key in all 9 locales with real translations (no English fallback), parity script clean. Tests: the existing 'uses total_prints as denominator' assertion is inverted to assert the new behaviour (40 / 20 / 35 → 67% gauge, Cancelled: 35 visible). The unchanged-display path (140 / 10 / 0 → 93%) still holds since 140 / (140 + 10) = 93.33% rounds the same. 33 StatsPage tests + 6 backend stats/failure tests green. |
||
|
|
3058c5789b |
fix(slice): @BBL name fallback for users without slicer bundles (#1325 follow-up)
The first cut of #1325 swapped a stale hardcoded model table for bundle-based compatibility matching: a cloud / standard process or filament preset was classified by consulting the user's uploaded Slicer Bundles (.bbscfg). That works perfectly when bundles cover every printer in the user's cloud catalogue, and silently no-ops otherwise - every cloud preset resolves to 'unknown', nothing moves into "Other printers", and the dropdowns look identical to the pre-fix state. The reporter saw exactly this on a clean install with no bundles uploaded. Restored BambuStudio's `@BBL <token>` name convention as a third tier below the bundle path, but driven by the canonical backend PRINTER_MODEL_MAP - exposed via a new GET /slicer/printer-models route - rather than a manually-maintained frontend table. The matcher inverts the registry into short-code -> printer-fragment ("X1C" -> "X1 Carbon"), normalises whitespace + case so "A1 mini" and "A1 Mini" compare equal, and falls back to raw-token compare for models not yet in the registry (so a future "Q1" matches without a code change). Adding a new Bambu model still touches exactly the one backend file already listed in the Bambu Model Codes registry. Tests: 2 new in test_slicer_presets.py (route returns the full PRINTER_MODEL_MAP, route returns a copy not the live dict); 11 new in slicerPrinterMatch.test.ts covering registry-driven X1C vs X1 Carbon, A1 vs A1 mini, H2D vs H2D Pro, P2S / H2C / H2S / X2D (which the original hardcoded list was missing), raw-token fallback, registry-not-loaded-yet degradation, and the precedence rules between compatible_printers / bundle / @BBL name. 38 slicer-presets + 36 slicerPrinterMatch + 34 SliceModal tests green; backend ruff clean; frontend build clean. |
||
|
|
4686d108ef |
feat(slice): cross-printer re-slicing across nozzle classes + multi-plate slice-all
Re-slicing a 3MF authored for a single-nozzle printer (X1C, P1S, A1, P2S)
onto a dual-nozzle printer (H2D / H2D Pro) — or vice versa — previously
failed with "G-code in unprintable area of multi-extruder printers" (the
source's bed-coordinate layout lands in the H2D's per-nozzle dead zone)
or, on multi-color projects, a hard SIGSEGV inside the slicer's ZFiller
polygon-clipping. Earlier shipped a fail-fast 400 guard; this drop lifts
it and actually does the conversion by forwarding the sidecar's existing
--arrange flag when the source and target nozzle classes differ. BS
itself reconciles the embedded project_settings.config against the new
printer that way, the same way the GUI's "Switch Printer" operation
does. The guard becomes a kept-for-compat no-op.
Slice-all-plates added to the SliceModal: a checkbox for multi-plate
sources sends plate=0 to the backend, which forwards --slice 0 to the
BS CLI. Same-class slice-all produces one multi-plate output 3MF in a
single sidecar call. Cross-class slice-all loops per plate (BS's
--arrange is project-wide and would otherwise consolidate every plate's
objects onto one bed) and merges the per-plate outputs into one
multi-plate 3MF locally via the new merge_plate_3mfs helper. The toast
shows "Plate 2 of 5 — Generating G-code (47%)" through the loop.
Three side fixes surfaced during testing:
- substitute_unused_plate_filaments overwrites unused-slot filaments
with the slot-1 selection before slicing so BS's loaded-filament
temperature validator doesn't reject a PLA print whose unused slot 2
defaulted to ABS in the dropdown
- re-sliced archive thumbnail now prefers the source's per-plate
render (Metadata/plate_N.png) over the project-wide MakerWorld cover
art, because BS CLI with --arrange skips writing a fresh per-plate
preview
- re-sliced archive bed_type now lifts from the sliced output's
curr_bed_type onto the PrintArchive column the card actually reads
Schema: SliceRequest.plate range relaxed from ge=1 to ge=0 to admit
the "all plates" sentinel; SlicerApiService.slice_with_profiles /
slice_with_bundle take an `arrange` parameter.
Tests: 26 in test_slicer_3mf_convert (count / merge / substitute /
extract), 3 in test_slicer_api (arrange wire format), 9 in
test_library_slice_api (guard no-op, bed_type lift, thumbnail
fallback, new cross-class slice-all loop integration test), 2 in
test_archive_service (Auxiliaries fallback), 4 in SliceModal.test
(plate=0 toggle), 2 in SliceJobTrackerContext.test (multi-plate toast
prefix). 659 backend + 42 frontend green; backend ruff clean,
frontend build clean, i18n parity green at 4984 keys × 9 locales.
|
||
|
|
7ea4410b21 |
fix(queue): insufficient-filament warning now fires on every dispatch path (#1496)
The pre-print deficit warning from #720 only ran inside the PrintModal submit flow. Both the green ▶ button on a staged queue row (POST /queue/{id}/start) and the Virtual Printer queue-mode intake bypassed it — auto_dispatch=True VP intakes would dispatch unsupervised onto spools that physically can't complete the print. Extracted the deficit check into backend/app/services/filament_deficit.py (single source of truth, both internal inventory and Spoolman modes). POST /queue/{id}/start returns 409 with a structured deficit payload unless ?skip_filament_check=true. The dispatch scheduler runs the same check before each _start_print; a deficit promotes the item to manual_start + sets a new filament_short flag (idempotent migration on print_queue). The flag clears automatically on the next tick when the operator swaps a spool to one with enough material. Frontend ▶ catches the 409 and opens a confirm modal showing each shorted slot's required vs remaining grams; the row now renders a yellow "Insufficient filament" badge when filament_short is set. Translated across all 9 locales. |
||
|
|
e222a0ef0e |
feat(system): log-health scanner + Add/Edit-Printer setup pre-flight
Adds a passive log-health check that complements the active Connection Diagnostic. Scans Bambuddy's recent app log against a curated allowlist catalog of known failure signatures (rejected access code, FTPS :990 timeout, FTPS TLS failure, flapping MQTT, unreachable camera, SQLite "database is locked" contention), dedupes and classifies each finding as layer8/environment/bug, and deep-links to the troubleshooting wiki. Sample log lines are sanitized before they leave the process. Exposed via GET /system/health and surfaced on two surfaces sharing one SystemHealthPanel component: a System Health section on the System page, and inline in the bug reporter when the form opens. The Add-Printer and Edit-Printer dialogs gained a setup-time pre-flight: saving runs the connection diagnostic and, on a failed check, warns with a "save anyway" escape hatch instead of silently saving a printer that will immediately show offline. Log read/parse/sanitize primitives extracted from routes/support.py into a shared services/log_reader.py (behaviour-preserving); affected support tests repointed accordingly. Tests: test_log_health.py (11), test_system_api.py (2 new), SystemHealthPanel + BugReportBubble + AddPrinterPreflight + EditPrinterPreflight (8 frontend). All strings translated across the 9 locales. Backend ruff clean, full unit suite green, frontend build + eslint clean, i18n parity green. |
||
|
|
6bc6a1d683 |
feat(virtual-printer): setup diagnostic + one-click slicer-certificate export
Two recurring virtual-printer support pains, both on the Virtual Printers
settings page.
Setup check: a stethoscope action on each VP card runs a pass/fail/warn/skip
checklist — VP enabled, services running, bind interface still exists, access
code set, target printer (proxy mode), and a live TCP probe of the FTP / MQTT
/ discovery ports on the bind IP. start_server swallows per-service bind
errors, so a service object can exist while nothing is listening; probing the
bind IP from outside is the only reliable signal and it catches the common
"VP not visible in the slicer" bind-IP-conflict and stale-interface cases.
Slicer certificate: virtual printers present a TLS cert signed by a shared CA
the slicer must trust. Until now users had to docker exec in and cat
bbl_ca.crt. A "Slicer certificate" row on the settings card now offers Copy
and Download (bambuddy-virtual-printer-ca.crt) plus the SHA-256 fingerprint.
GET /virtual-printers/ca-certificate returns only the public certificate; the
CA private key never leaves the backend. The CA is generated on demand so the
button works before the first VP is enabled.
Backend:
- services/virtual_printer/diagnostic.py — run_vp_diagnostic + port probes
- schemas/virtual_printer.py — VPDiagnosticResult
- CertificateService.get_ca_certificate_info() + manager helper
- routes: GET /virtual-printers/ca-certificate, /{vp_id}/diagnostic
Frontend:
- VirtualPrinterDiagnosticModal.tsx; stethoscope button on VirtualPrinterCard
- caCert row on VirtualPrinterList; utils/clipboard.ts (shared copy w/
non-secure-context fallback + downloadTextFile), de-duplicating the
existing FQDN-copy logic
- vpDiagnostic.* + virtualPrinter.caCert.* across all 9 locales
9 backend unit tests + 4 route integration tests + 6 frontend tests.
Backend ruff clean, frontend build clean, i18n parity green.
|
||
|
|
e0247fc6a6 |
fix(slicer): filter process/filament presets by uploaded bundles, not preset names (#1325)
The process dropdown still mixed @BBL P2S presets into an X1C list: slicerPrinterMatch matched cloud/standard presets by parsing the @BBL <model> name suffix against a hardcoded model-code allow-list that was missing P2S, H2C and X2D — so those presets resolved to "unknown" and stayed in the main list instead of "Other printers". Drop both hardcoded model tables. Compatibility now comes from the user's uploaded Slicer Bundles: a bundle is scoped to one printer and lists the presets it ships, so a preset matches a printer exactly when some bundle for that printer contains it. New models are covered the moment their bundle is uploaded. |
||
|
|
7eba29624b |
feat(slicer): filter process & filament profiles by selected printer (issue #1325)
The Slice dialog listed every process / filament preset regardless of the chosen printer (#1325). Picking a printer profile now filters both dropdowns to compatible presets; presets resolving to a different Bambu model move into a trailing "Other printers" group. Matching uses the slicer's own compatible_printers list for imported (local) presets, and falls back to the "@BBL <model>" name suffix for cloud / standard presets where no compatibility metadata is available, so all three tiers are covered. Compatibility-unknown presets (custom or untagged) are never hidden. The pre-pick and printer-switch paths follow the same rule. - UnifiedPreset gains compatible_printers, exposed for the local tier - new frontend util slicerPrinterMatch.ts with the matching logic - slice.otherPrinters added across all 9 locales |
||
|
|
76e327f4a1 |
feat: connection diagnostic for "printer won't connect" triage
A triage review of the last 200 closed issues found ~1/3 were
user-side setup errors — printer not in LAN developer mode, blocked
ports, Docker bridge networking, wrong access code, cross-subnet —
each costing a multi-round-trip support exchange.
Add a Connection Diagnostic that runs those checks automatically:
- backend/app/services/printer_diagnostic.py: TCP probes of MQTT
8883 / FTPS 990 / RTSPS 322, LAN developer mode, Docker network
mode, printer/host subnet match, MQTT credential class; each
check returns pass/fail/warn/skip with a localized fix.
- Routes: GET /printers/{id}/diagnostic (saved printer) and
POST /printers/diagnostic (pre-save Add-Printer flow).
- ConnectionDiagnostic.tsx: modal + shared checklist, surfaced from
the printer card actions menu, an offline-printer quick button,
the Add-Printer dialog, and a new System-page section.
- The in-app bug reporter scans configured printers when the form
opens and always shows the result inline — a healthy confirmation,
or the detected problem and its fix.
- config.yml troubleshooting link repointed to the rendered wiki
page; bug_report.yml gains a diagnostic checkbox.
Diagnostic strings translated across all 8 locales. Backend service
unit tests (15) + frontend modal tests (3). Ruff clean, frontend
build clean, i18n parity green.
|
||
|
|
1677efb2c6 |
fix(labels): replace incorrect ams_30x15 preset with correct AMS holder sizes (#1426)
Reporter — the same person who originally requested the labels feature in #809 — discovered that the ams_30x15 preset's 30x15 mm dimension didn't actually fit any variant of the MakerWorld AMS Filament Label Holder (model 752566) it advertised. Two new presets replace it: - ams_holder_74x33 (74 x 33 mm) matches the printable label STL bundled in the MakerWorld project - ams_holder_75x55 (75 x 55 mm) fits the cardstock-insert variant the reporter validated on bench Both cross the 20 mm height threshold so they land in the roomy layout branch — swatch on the left, QR on the right, multi-line text (brand, material, hex code, spool ID) in the middle. The old 30x15 mm preset couldn't fit a QR code; the new ones do. No DB migration: the preset name was never persisted. Callers scripting the old ams_30x15 value get a clean 422 at the route's Literal validator with the new valid values listed. i18n: replaced inventory.labels.templates.ams.{label,hint} with amsHolderSmall and amsHolderLarge across all 8 locales with real translations; parity guard cleaned of the stale English-fallback cognate entries. Parity holds at 4856 leaves per locale. Tests: backend label renderer + integration tests cover both new presets; LabelTemplatePickerModal test updated for the 6-button grid and the new template value in the API-call assertion. |
||
|
|
feb44a9dc1 |
Merge pull request #1416 from benhalverson/fix/open-in-slicer
Fix library Open in Slicer URLs for extensionless filenames |
||
|
|
134847a3bd |
feat(camera): in-app diagnostic for "Connection lost" (#1395 follow-up)
Step 2 of the camera architecture overhaul agreed after #1395. When the camera viewer hits its error state OR before a print at any time, a Diagnose button runs a staged check against the printer and renders the result inline: which stage failed, how long it took, and a translated remediation hint. Cuts off the "user opens a 'camera broken' ticket → ask for support bundle → triage" loop at the user's screen. Backend - New `backend/app/services/camera_diagnose.py` orchestrator with CameraDiagnoseResult / CameraDiagnoseStage dataclasses. - New POST /printers/{id}/camera/diagnose route in camera.py. - Stages: tcp_reachable — TCP socket open to 322 (RTSP) / 6000 (chamber) with 3 s timeout. Distinguishes timeout, refused, and host- unreachable into distinct summary codes so the frontend can show a precise remediation (firewall vs LAN-only off vs wrong IP). first_frame — captures one JPEG end-to-end via the existing capture_camera_frame_bytes pipeline. Auth + RTSP handshake + first keyframe collapse into one stage; the user-facing answer is the same regardless of which sub-layer failed. - Live-stream shortcut: when a viewer is currently watching the camera with a buffered frame < 10 s old, the diagnostic skips the real test and returns live_stream_active_healthy. Opening a fresh socket would kick the live viewer off on single-camera- connection firmwares (the #1348 reconnect-storm trigger), so we trust the real-world evidence instead. - Response surfaces protocol, port, and profile name for support triage — lets us ask "what does your modal say?" instead of "send the support bundle". Frontend - New CameraDiagnoseModal renders one row per stage with green- check / red-X / grey-skipped icons, the per-stage duration in ms, a remediation banner styled by overall status, and a Run again button. - Two entry points: 1. The viewer's error overlay grows a Diagnose button next to Retry. Retry stays the primary action; Diagnose is the escape hatch for users who can't see what's wrong. 2. A stethoscope icon in the viewer's always-visible control bar, between Refresh and Fullscreen. Pre-flight testing ("did my firmware update break the camera?", "is the camera up before I send a print?") doesn't require waiting for the stream to fail first. - Also lifted the previously-hard-coded "Camera unavailable" / "Retry" strings into camera.unavailable / camera.retry so the error UI is fully translated alongside the new keys. |
||
|
|
e61a454a0f |
fix(inventory): "Reset usage to 0" preserves remaining in both modes (#1390)
Reporter saw a 544 g spool jump to 1000 g after pressing the eraser.
"Spools and remaining weights are not changed" - the dialog promised
this; the implementation did the opposite. Root cause was an
architectural conflation: `weight_used` did double duty as the
resettable "consumed since tracking started" counter AND as the basis
for the displayed remaining (`label_weight - weight_used`), so zeroing
it correctly cleared the stat but unavoidably reset remaining to full.
Spoolman has separate `used_weight` and `remaining_weight` fields, so
the API call there was correct - but Bambuddy's frontend was also
computing remaining as `label_weight - weight_used` for Spoolman
spools (ignoring Spoolman's real `remaining_weight` field), so the
same visual bug bit there too. Inventory-mode parity required fixing
both halves in one drop.
Internal mode
- New `weight_used_baseline` column (Float DEFAULT 0) on `spool`.
- Reset stamps `baseline = weight_used` and leaves `weight_used` alone.
- Displayed consumed = `weight_used - baseline`; remaining =
`label_weight - weight_used` (unchanged).
- Subsequent prints continue to grow `weight_used`, so the resettable
counter naturally tracks post-reset delta and remaining keeps
decrementing across the reset.
Spoolman mode
- `_map_spoolman_spool` now reads Spoolman's `remaining_weight` field
and returns a synthetic `weight_used = label - remaining` so the
frontend's remaining calc matches Spoolman's real stored value;
`weight_used_baseline = synthetic - real_used_weight` so the consumed
counter (`weight_used - baseline`) matches Spoolman's `used_weight`.
- Fallback path (no `remaining_weight` set) preserves the old behavior.
- Related fix: `update_spool` (Spoolman PATCH) was deriving the default
`weight_used` from `used_weight`, so editing unrelated fields AFTER
a reset would patch Spoolman with `remaining_weight = label - 0 =
label`, trampling the real value. Now derives from
`remaining_weight` so non-weight edits preserve physical state.
Frontend
- `InventoryPage` `totalConsumed` aggregate switched to
`Math.max(0, weight_used - (weight_used_baseline ?? 0))`.
- `ForecastPanel` `computeDeltaRate`, `totalUsedG`, and the per-spool
"consumed" table cell got the same treatment so forecast and
inventory aggregates stay coherent across a reset.
- `?? 0` keeps pre-migration installs rendering correctly until
`init_db()` runs the idempotent ALTER TABLE.
Migration
- `ALTER TABLE spool ADD COLUMN weight_used_baseline REAL DEFAULT 0`
via `_safe_execute` - SQLite and Postgres both accept it; verified
end-to-end on Postgres 16.
|
||
|
|
b51598ea69 |
fix(printers): refuse to add a printer when the MQTT probe fails (#empty-card-reports)
Several support reports traced back to one root cause: a mistyped access
code in Add Printer left an empty card on the dashboard. POST /printers/
was persisting the row first, then firing connect_printer() fire-and-forget.
Now we test_connection() BEFORE the insert; failure returns HTTP 400 and
the row is never written.
Structured error response -- detail={"code", "message"} -- so the toast
shows the localized message instead of the English fallback. New
ApiError.code field on the frontend; printers.toast.connectionFailedNotAdded
|
||
|
|
48a7024b96 |
security(github-backup): refuse to save against a non-private repository
While auditing real-world Bambuddy backup repos on GitHub I found
several left public. That's a serious leak: the settings backup only
filters bambu_cloud_token and auth_secret_key, so mqtt_username,
mqtt_password, ha_token, prometheus_token, bambu_cloud_email,
external_url, and the printer access codes (via K-profiles) were going
to whatever visibility the user picked.
Hard guard at every save and re-checked on every push:
- POST /github-backup/config and PATCH /github-backup/config (when URL,
token, or provider changes) run a connection test internally and
return 400 unless is_private comes back True.
- run_backup() re-checks before each scheduled or manual push, so a
repository that flipped from private to public gets a clear
"Backup aborted: the target repository is no longer private" failure.
Each provider's test_connection now returns is_private (GitHub /
Gitea / Forgejo read data.private, GitLab reads visibility=="private";
"internal" is treated as non-private). None means "couldn't determine"
and is also rejected -- safer to fail closed.
Frontend renders visibility inline on Test Connection: green check when
private, red warning panel listing every credential at risk when public,
yellow when unknown.
---
ui(github-backup): show save-failure messages inline on the card
The new "repository is not private" rejection message is ~250 characters
listing every credential the backup carries (MQTT password, HA token,
Prometheus token, Bambu Cloud email, printer access codes), which clips
badly in a toast.
Both the initial-setup save and the debounced autosave now stash the
backend's error message into a saveError state and render it as a red
inline banner above the test-result block, with whitespace-pre-wrap so
the full message stays readable. The banner clears on success, on the
next save attempt, and when the user starts editing URL / token / provider
-- the three fields whose changes invalidate the privacy check -- so it
doesn't linger after the user has already addressed the cause.
Short success toasts (Settings saved, Token updated, Backup enabled) are
unchanged.
|
||
|
|
8b9efd0160 |
fix(inventory): "Reset usage to 0" works in Spoolman mode too (#1390)
First cut of this action only wired the built-in inventory path, so the
eraser buttons vanished when the user switched to Spoolman mode. Mirror
the endpoints on the Spoolman router:
- POST /spoolman/inventory/spools/{id}/reset-usage
- POST /spoolman/inventory/spools/reset-usage-bulk
Both route to a new SpoolmanClient.reset_spool_usage() helper that PATCHes
/spool/{id} with used_weight=0. The bulk variant keeps the same typo-wipe
guard (rejects empty/missing spool_ids), and individual Spoolman failures
are logged + counted out without aborting the batch.
InventoryPage mutations now switch on spoolmanMode to pick the right
client method, and the three "spoolmanMode ? undefined : ..." gates on
the eraser buttons are gone.
|
||
|
|
6f2cec5eb3 |
feat(smart-plugs): auto-off after AMS drying completes (#1349)
Reporter Kyobinoyo asked for the equivalent of the existing print-finish auto-off but triggered when AMS drying ends. Two new SmartPlug columns: auto_off_after_drying (default false), off_delay_after_drying_minutes (default 10 — AMS chamber is hot post-cycle so longer cooldown than the print-finish default of 5). SQLite + Postgres migrations both idempotent. Trigger lives in BambuMQTTClient — per-AMS _previous_dry_times tracks the dry_time > 0 → 0 falling edge and fires a new on_drying_complete(ams_id) callback. Plumbed through PrinterManager.set_drying_complete_callback to SmartPlugManager.on_drying_complete(printer_id, db), which walks linked plugs and respects the per-plug toggle. Catches queue, ambient and manual drying identically because it observes firmware state, not scheduler intent. Frontend: single "Auto Off After Drying" toggle + delay input on the smart plug card, next to the existing print-finish auto-off section. Per-AMS plug routing (separate plug for AMS only, per-AMS targeting on dual-AMS printers) deferred — Bambuddy's plug model is plug→printer, so the trigger fires whenever any AMS on the linked printer finishes a cycle. |
||
|
|
e7045597bc |
fix(archives): bulk and auto purge now honour the soft / hard delete choice from #1343 (#1390 follow-up)
Reporter IndividualGhost1905 followed up after the #1378 / #1343 backfill landed and pointed at the next inconsistency: the per- archive delete dialog has had a "Also remove this print from Quick Stats" checkbox since #1343, but the "Purge Old" button and the scheduled daily auto-purge sweeper both ignored that choice and hard-deleted unconditionally. From the user side this looked like "automatically deleted from statistics without any warning" — half- true, and the inconsistency was real either way. The actual current shape (before this fix): - POST /archives/purge -> archive_purge_service.purge_older_than -> ArchiveService.delete_archive (hard). Archive row dropped. Linked PrintLogEntry rows have ON DELETE SET NULL so they survive as orphans with archive_id=NULL. Quick Stats keeps the filament / cost / energy contribution because the log rows are still there, but the archive-list-iterating widgets (Filament Trends, By Material, Color Distribution, Printer Stats) lose the row, and Time Accuracy loses its join target. Visibly inconsistent. - Scheduled _maybe_run_auto_purge -> same code path, same effect. - Single-archive DELETE /archives/{id} -> already takes purge_stats=true|false (default false=soft) and routes either soft_delete_archive (keeps everything, flips deleted_at) or deletes PrintLogEntry rows first + hard-deletes archive. The fix threads the same purge_stats flag through every bulk surface with soft as the default, matching the single-archive default: Backend: - archive_purge_service.purge_older_than(..., purge_stats=False) -> per-row soft_delete_archive when False, per-row PrintLogEntry deletion + delete_archive when True. Each runs in its own session (same pattern the sweeper already used). - preview_purge gains the same kwarg so the eligible-count matches what an actual run would touch: soft mode excludes already-soft-deleted rows, hard mode counts them as eligible for promotion. - get_settings / set_settings now persist archive_auto_purge_stats (default False). _maybe_run_auto_purge reads it on every tick. - ArchivePurgeRequest / ArchivePurgeResponse / ArchivePurgeSettings schemas extended. - Route /archives/purge accepts the body flag, /purge/preview accepts the query param, /purge/settings GET + PUT echo the setting. Frontend: - "Purge old archives" modal: new "Also remove from statistics" checkbox under the preview, unchecked by default. Plumbed into the preview query key + the execute mutation. - Settings -> Archives auto-purge card: matching toggle next to the days slider, disabled when auto-purge itself is off. - api.previewArchivePurge / api.executeArchivePurge accept the flag; ArchivePurgeSettings type gains purge_stats. - All 8 locales (en, de, fr, it, ja, pt-BR, zh-CN, zh-TW) get new purgeStatsLabel / purgeStatsHint / purgeStatsDescription keys, plus rewritten effect / warning copy in archivePurge and archiveAutoPurge to reflect that the default no longer "permanently removes from the database" but instead hides the row + removes files while keeping Quick Stats intact. i18n parity check clean: 4814 keys across all 8 locales, no fallback. Behaviour change for existing users on auto-purge: the sweeper used to hard-delete by default and now soft-deletes by default. After the upgrade those installs start *preserving* more data in Quick Stats rather than losing it — safer direction of the two, but worth the explicit call-out. Anyone who wants the old behaviour ticks the new toggle once and it persists. |
||
|
|
84ed28d5fa |
fix(queue): cancel pending items and hide stale archive surface when archive soft-deleted (#1348)
Opening the Print Queue page fired 404s on /archives/{id}/thumbnail,
/archives/{id}/plates, and /archives/{id}/plate-thumbnail/{n} for any
row pointing at a soft-deleted archive. Two underlying problems wearing
one mask:
1. Cosmetic: the queue API was copying item.archive.thumbnail_path into
archive_thumbnail without checking deleted_at. Soft-delete leaves the
row (so the relationship resolves) but removes the file from disk, so
the cached path was always stale.
2. Functional: a queue item whose 3MF was removed can never dispatch.
Without an explicit cancel, the item sits in 'pending' forever with
no indication to the user about why nothing is printing.
Fix in three parts:
- New _cancel_pending_queue_items() helper, called from soft_delete_archive
alongside the existing print-log thumbnail cleanup. Sets status='cancelled'
+ waiting_reason='Source archive deleted' on every pending queue item
linked to the archive. Only 'pending' is touched - completed/failed/
cancelled rows are historical and untouched. Hard-delete is already
covered by ON DELETE CASCADE on print_queue.archive_id.
- Queue API serializer now checks item.archive.deleted_at before
populating any archive-derived field. New archive_deleted: bool field
on PrintQueueItemResponse signals the soft-deleted state.
- Frontend's getArchivePlates query in QueuePage was gated on archive_id
only - archive_id is the real FK and stays exposed for dispatch/audit,
so added an explicit && !item.archive_deleted clause to respect the
new flag. Thumbnail render and CompactHistoryRow/QueueTimelineView
already gate on archive_thumbnail so the backend suppression alone
covers them.
Regression tests pin cancel-only-pending behavior, soft-deleted
suppression + archive_deleted=True flag, and the sanity guard that
live-archive fields keep flowing through unchanged.
|
||
|
|
856b849ffa |
fix(stats): per-event aggregation so reprints add to Quick Stats instead of overwriting (#1378)
Statistics now aggregate over PrintLogEntry (one row per print event,
the same table backing the global Print Log) rather than PrintArchive
(one row per file). A reprint creates a new PrintLogEntry instead of
overwriting the source archive's runtime fields, so:
- a 100 g successful print + a 10 g failed reprint correctly sums to
110 g / 2 prints / 1 successful / 1 failed in Quick Stats and the
Prometheus /metrics endpoint (previously the failed reprint silently
replaced the source archive's data; totals dropped from 100 g to 10 g)
- the archive's card cost/energy_kwh are preserved on reprints (only
the first run writes them); per-run actuals live on PrintLogEntry
- failed/cancelled/stopped reprints record partial-aware filament: sum
of tracked spool deltas when inventory is set up, else estimate
scaled to progress%, else None — prevents the full slicer estimate
from inflating totals on a print that stopped at 10 % progress
PrintLogEntry gains six columns: archive_id (nullable FK, ON DELETE
SET NULL so log entries survive archive deletion preserving #1343
soft-delete-vs-stats decoupling), cost, energy_kwh, energy_cost,
failure_reason, created_by_id. Idempotent SQLite + Postgres migrations.
New per-archive surface:
- archive list response carries run_count / last_run_at /
total_filament_actual_grams / successful_run_count / failed_run_count
via a single batch JOIN, no N+1
- new GET /archives/{id}/runs endpoint returns every PrintLogEntry for
the archive (ARCHIVES_READ permission, newest-first ordering)
- archive cards render an orange "N prints" badge for archives with
more than one run; clicking the badge opens a dedicated PrintLogModal
with date/status/duration/filament/cost columns plus failure_reason
under failed runs. Also reachable via the context menu's new "Print
Log" entry (works for single-run archives too), and embedded at the
top of the Edit Archive modal for context.
The purge_stats=true delete path now hard-deletes linked PrintLogEntry
rows up front so the archive's contribution truly leaves the totals;
without it, ON DELETE SET NULL would orphan the runs and leave them
counting toward stats.
|
||
|
|
29379e3be7 |
fix(camera): plate-detection UI now uses the external camera when configured (#1359)
Reporter @Andlar94 hit a permanent "Build plate not empty" on every print start on an A1 with an external RTSP camera. The runtime auto-check at main.py:1819 called check_plate_empty with use_external=external_camera_enabled, but the manual UI routes (camera.py) declared use_external: bool = False and the frontend client always sent use_external=false. So calibration captured a built-in frame and stamped it as the reference; the runtime check captured an external frame and diffed it against that reference -- a permanent mismatch. Centralise the default on the backend: both routes now take bool | None, deriving the default from the printer's external_camera_enabled + external_camera_url + external_camera_type. The frontend client stops sending the flag unless the caller explicitly sets it, so the existing UI call sites immediately benefit and any future caller gets the right camera automatically. Explicit overrides still win. Adds 4 regression tests pinning the new default for both the external-enabled and external-disabled cases, plus the explicit override path so a future "always built-in" caller stays supported. |
||
|
|
ae29a7dcd3 |
fix(api-keys): expose narrowly-scoped "Update electricity price" toggle (#1356)
Reporter @maziggy followed the Energy Tracking wiki literally - "create a
key with Write Settings permission, PATCH /api/v1/settings with
{energy_cost_per_kwh: ...}" - and hit:
{"detail":"API keys cannot be used for administrative operations"}.
Triage showed three independent drifts:
1. Wiki listed nine fictional API-key permissions (Read Printers / Write
Settings / Admin / ...) but the UI only ever exposed four toggles
(Read Status, Manage Queue, Control Printer, Allow Cloud Access).
There was no Write Settings toggle to tick.
2. Even if it had existed, the backend hard-denies SETTINGS_UPDATE for
every API key via _APIKEY_DENIED_PERMISSIONS - intentional protection
because PATCH /settings can rewrite SMTP/LDAP/MQTT credentials and the
HA access token. Wider surface than any documented use case needs.
3. So the wiki had been promising a workflow that was never deliverable.
Fix: introduce a narrowly-scoped door rather than relax the deny list.
- New column can_update_energy_cost (default FALSE - existing keys
never silently gain settings-write capability on upgrade).
- New route POST /api/v1/settings/electricity-price accepting
{"energy_cost_per_kwh": <float >= 0>}. Field name matches what the
wiki already documented so the HA rest_command example needs only a
URL+method change, not a payload change.
- Custom dependency require_energy_cost_update() bypasses
_APIKEY_DENIED_PERMISSIONS for this one route for API keys with the
flag set. JWT users still go through standard SETTINGS_UPDATE.
- General PATCH /settings remains denied for API keys - flipping the
narrow flag does NOT widen general settings-write access. Pinned by
test_patch_settings_still_denied_with_energy_flag.
Frontend: fifth "Update electricity price" toggle on the create-API-key
card + amber "Energy" badge on existing keys with the flag set. Three
new i18n keys across all 8 locales (German translated, English fallbacks
elsewhere).
|
||
|
|
d6364646f8 |
feat(auth): manual LDAP user provisioning from the UI (#1298)
Reporter @Fuechslein flagged that disabling LDAP auto-provision left admins
with no UI path to onboard new users — the create-user form had zero LDAP
awareness and the only workaround was hand-editing the database.
Add a Local / LDAP tab toggle to the create-user modal (hidden when LDAP is
disabled). The LDAP tab is a debounced directory search (≥2 chars, 300ms)
that returns up to 25 matches via the service-account bind, annotated with
already_provisioned so existing usernames render disabled. Clicking
"Provision user" re-resolves via the service bind and creates the user
through the same _provision_ldap_user helper the auto-provision login path
uses, so group mapping, default-group fallback, and email sync are identical
regardless of which path created the user.
The picker component is shared across all four create-user modal paths
(UsersPage basic + advanced, SettingsPage basic + advanced).
Two ldap3 schema-check workarounds were needed for OpenLDAP installs:
- Open the search connection with check_names=False so ldap3 doesn't reject
the cross-schema OR filter (sAMAccountName/displayName are AD-only)
- Request attributes=["*"] because ldap3's build_attribute_selection
validates each named attribute against the server schema regardless of
check_names, and only the * wildcard is in its hard-coded exclusion list
Login/lookup paths keep check_names=True so typos in user_filter still fail
loudly.
Backend
- New routes: GET /auth/ldap/search, POST /auth/ldap/provision (both gated
by USERS_CREATE; 503 details include ldap3 exception class + message)
- Extract _open_service_connection + _extract_user_info helpers so
authenticate_ldap_user, lookup_ldap_user, and search_ldap_users share the
bind and attribute-extraction logic
Frontend
- New LdapUserPicker component (debounced search, result list, provision
mutation, already-provisioned guard, error surface)
- Tab toggle wired into UsersPage and SettingsPage modals, plus
CreateUserAdvancedAuthModal props
- 14 i18n keys added to en.ts (other locales fall back to English)
|
||
|
|
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).
|
||
|
|
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. |
||
|
|
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.
|
||
|
|
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.
|
||
|
|
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
|
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |