Commit Graph
1414 Commits
Author SHA1 Message Date
maziggy 29311cab2b fix(backup): refuse prometheus_enabled when the backup has no token either (#2656)
The companion-credential rule has five conditions, and the second one -- "the
    backup itself carried a usable credential" -- was applied to all five pairs. It
    should not be. It is what stops the rule over-refusing an anonymous MQTT broker
    or an anonymous LDAP bind, both of which are working configs: there, an empty
    credential in the backup means the restore is not producing anything weaker
    than what was backed up.

    For prometheus_enabled it does not transfer. An empty prometheus_token removes
    /api/v1/metrics' only gate, so the exposure is a property of the toggle, not of
    a downgrade relative to the backup -- and prometheus_token is optional, so a
    backup taken on an instance that enabled Prometheus without ever setting one
    carries the toggle and no usable token. That payload skipped the refusal
    entirely: not a candidate, so the local-state pass never ran, and the blocklist
    quietly dropped the token key. On a token-less target the result was
    prometheus_enabled=true, no token row anywhere, and a full unauthenticated
    metrics dump -- the same hole the rule was written to close, reached from the
    likelier of the two directions.

    So condition 2 is now per-pair: an exposure class (prometheus) that skips it and
    is judged on local state alone, and an availability class (mqtt, ldap, ha,
    virtual_printer) that keeps it. Nothing else changes -- the local-state pass
    already stands down when the instance has its own credential, when HA_TOKEN is
    in the environment, and when the toggle is already on locally, so "the exposure
    pre-dates this restore" still holds and refusals still get no tally increment.

    One wording consequence: an exposure toggle can now be refused on a payload with
    no credential-like key in it at all, where the shared caveat would have read "0
    credential-like key(s) will be skipped". That case gets its own preview detail
    code, settingsCompanionOnlyWillSkip, in all 13 locales.

    Tests: 6 that fail pre-fix -- the token key absent and blank at the unit level,
    the new preview wording, and the integration test through the real endpoint for
    both payloads (200 with a full metrics body before this, 404 after). Plus 3
    controls, because over-refusal is still the real risk: the exposure route must
    still stand down for a local token and for an already-on toggle, and the
    availability class must still let a credential-less mqtt/ldap/virtual_printer
    toggle through. The anonymous-broker and anonymous-bind controls are unchanged
    and still pass.
2026-08-15 14:15:13 +02:00
maziggy 812a70f326 fix(backup): stop overwrite writing a spool's other tag key (#2656)
Four of the review's smaller items.

    E6, the substantive one. tag_uid and tray_uuid are both in the overwrite setattr
    loop, so a spool matched on one key got the backup's *other* key written onto it.
    Neither column has a unique constraint (models/spool.py, and no unique index in
    the migrations), so nothing errors — a duplicate tag simply appears, after which
    _find_spool's .scalars().first() is non-deterministic and an AMS tag lookup
    resolves to an arbitrary one of the two spools. The same loop could also clear a
    tag the user had scanned since the backup was taken, when the backup entry held
    None.

    _find_spool now reports which key matched, and _guard_tag_overwrite drops a tag
    column from the write when the incoming value is empty and the local row has one
    (the backup predates the scan, so the local tag is the newer fact) or when
    another local spool already holds it. Announced in the tally the way the archive
    un-delete case already announces itself, rather than done silently — the
    spoolTagKept locale key landed with the rest of the i18n block last commit.

    E5. The Restore button is hidden without github:restore. All three endpoints are
    gated on it server-side, so the modal 403s on its first preview; offering the
    button is offering an action that cannot work. Button only — the card stays
    visible, since configuring backups is a separate permission — and hasPermission
    returns true with auth off, so a single-user instance is unaffected.

    E3. models/github_backup.py: the trigger comment said manual/scheduled; this PR
    added a third value.

    E4. ha_token_from_env: recommending no change, with the reasoning recorded as a
    test rather than left in a review thread. It is built only in the settings GET
    response, is absent from AppSettingsUpdate, and is therefore never a Settings
    row — it cannot reach a backup, so an allowlist entry would be dead code. Worse,
    a name-shaped exception to a belt-and-braces denylist is a live hole: an
    attacker-authored settings/app_settings.json could get a *token*-named row
    written by choosing that name.

    4 unit tests and 1 frontend test that fail against this commit's parent, plus 4
    controls: a free tag is still written, an unchanged tag is not reported as kept,
    an insert is unaffected, and the button still shows with auth disabled.
2026-08-15 14:13:35 +02:00
maziggy 7d2cf440b1 i18n(backup): make the restore notes and preview details translatable (#2656)
A German user got a translated modal with "Not present in this backup commit" in
    the middle of it. Every tally note and preview caveat was a server-built English
    sentence rendered verbatim.

    Follows the backup.pathCheck contract already in use one card down in the same
    component, deliberately rather than inventing a second convention: the server
    sends a `code` plus typed `params` and carries the English along as the
    fallback, and the client renders
    `t(`...${code}`, { ...params, defaultValue: message })`. The defaultValue arm is
    what keeps a newer backend's unfamiliar code readable instead of printing the
    raw key — covered by its own test.

    Shapes:
    - notes: list[str] -> list[GitHubRestoreNote] {code, params, message}. Breaking,
      but the field is unreleased in this same PR.
    - GitHubRestorePreviewCategory gains detail_code / detail_params; `detail` stays
      as the English fallback.
    - _CategoryTally.note(code, message, **params), deduped on (code, params) rather
      than on the rendered text, so two offline printers both keep their names. The
      20-note cap is unchanged.

    28 new leaves across 13 locales: 20 notes.* and 8 details.*. `noData` collapses
    the four per-category "No X data in this backup" strings into one, since the
    category heading already renders beside it. Counts use single-form {{count}} in
    the existing "N record(s)" style rather than i18next plural suffixes — nothing in
    this block uses _one/_other and the parity script has extra rules for them.
    Parity holds at 5737 leaves in all 13 locales.

    Deliberately out of scope, and worth saying so rather than leaving it to look
    like an oversight: result.message, the commit-picker subject lines and the HTTP
    error strings stay English. Those also originate in the provider backends, so
    code-ifying them widens the diff well past the restore service.

    spoolTagKept is added here with the rest of the locale churn but is not emitted
    until the next commit, so the 13-locale change lands once.
2026-08-15 14:13:19 +02:00
maziggy 2e7ff24a1d i18n(backup): rename the Git restore button to "Restore from Git" (#2656)
Two buttons labelled "Restore" were visible in the same viewport — this card's
    and the Local Backup card's — with different destinations. The modal title
    disambiguated them; the buttons did not.

    backup.restoreFromGit.button only. The Local Backup card's t('backup.restore') is
    unchanged, and each locale's new value follows the wording that locale already
    uses in the modal title rather than being a literal translation of the English.

    One correction to the review's aside: this does not simplify
    GitHubRestoreModal.test.tsx. The /Restore$/ + confirmButtons[length - 1] idiom
    there is not caused by the card button — the modal is rendered standalone in
    those tests — but by the modal's own footer action and its confirm dialog both
    reading t('backup.restore'). Left alone as out of scope.
2026-08-15 14:13:04 +02:00
maziggy 049a638679 y fix(backup): don't count a stale selection, and say when archive links are dropped (#2656)
Two smaller restore-path fixes from the same review.

    The modal's footer counted `selected` raw while the checkboxes rendered
    `selected && isAvailable`. Switching commits keeps `selected` on purpose (it is
    only pruned once the new preview lands), so for as long as the new commit's
    preview was in flight — with the category list replaced by its spinner — the
    footer still read "2 selected" over an enabled Restore button, and clicking it
    restored the newly-picked commit with the previous commit's categories, none of
    which the user had seen an item count for. The count and the POST body now come
    from one `selectedCategories` memo gated on availability, exactly as the
    checkboxes are, so both go empty until the preview lands.

    Restoring Spool inventory without Print archives leaves archive_id_map empty,
    so every usage -> archive link is nulled even where the archive exists locally.
    It can't be resolved here (the archives payload isn't fetched for a category
    that wasn't selected) and a later archives-only restore won't repair it either,
    since the usage dedupe key doesn't include archive_id and those rows read as
    already-present. So it gets a note naming the remedy while the user can still
    redo the run with both categories ticked.
2026-08-15 14:12:11 +02:00
maziggy d06d134138 i18n(backup): add the Ukrainian strings for the restore modal (#2656)
The Ukrainian locale landed on dev in #2695, after this branch was cut,
    so uk.ts was missing the 23 restoreFromGit keys the rest of this branch
    adds to the other twelve. check-i18n-parity.mjs auto-discovers the
    locales directory, so it would have failed on the merge.

    All 13 locales are now at parity (5699 leaves each). Terminology follows
    what uk.ts already uses for the local-backup restore path — "Відновити"
    for restore, "резервну копію" for backup.
2026-08-15 14:11:16 +02:00
maziggy 5033c9c917 fix(backup): don't let SettingsPage revert a restored settings category (#2656)
Restoring App Settings from the Backup tab silently reverted almost everything
    it reported restoring.

    GitHubRestoreModal invalidated ['settings'] on success. SettingsPage — which
    renders the modal — keeps a `localSettings` copy of its form state alongside a
    debounced effect that PATCHes it back whenever the server copy differs. The
    refetch made the two differ, the effect cannot tell "server changed" from "user
    edited", and 500 ms later it wrote the pre-restore values back over the restore.
    75 of the ~80 keys in a backup sit in that save payload, so a restore reporting
    "77 restored, 0 failed" left only the five auth/internal flags behind. The
    backend was correct throughout: the same restore driven against the API with no
    browser open applies cleanly.

    Since the Restore button lives on the Settings page, this was the default path
    rather than an edge case.

    Fixed inside the modal rather than in SettingsPage: that debounce's own comments
    show it was tuned to avoid resetting text fields mid-typing, and widening this
    change into it risks that. So ['settings'] is no longer invalidated, and every
    exit path (footer Close, header X, overlay click, Escape) now reloads instead of
    closing when settings were among the restored categories, since leaving the page
    mounted is what arms the overwrite. The ['spools'] and ['archives']
    invalidations are unchanged — SettingsPage is the only page carrying this kind
    of whole-payload auto-save.

    The root cause is left for a follow-up: any future feature that writes settings
    server-side will be reverted the same way.

    Found by manual end-to-end testing against a private test repo, which also
    confirmed the natural-key id remapping and the overwrite-off behaviour working
    as designed.
2026-08-15 14:10:34 +02:00
maziggy 449924f887 feat(backup): restore selected categories from a Git backup commit (#2656)
The Git backup feature was push-only: there was no equivalent of the local
    backup's Restore button, so recovering meant hand-downloading JSON files from
    the repository. This adds the read side.

    Providers gain list_commits / list_tree / fetch_files on the GitProviderBackend
    ABC. GitHub implements them against the Git Data API and Gitea/Forgejo inherit
    that unchanged; GitLab overrides for its own REST shape, including tree
    pagination and subgroup path encoding. fetch_files is batched so the path ->
    blob SHA lookup happens once per restore rather than once per file, and uses the
    blobs API rather than contents because contents silently inlines only the first
    1 MB.

    The new GitHubRestoreService resolves HEAD to a concrete SHA up front, so a
    preview and the restore that follows act on the same commit even if a scheduled
    backup lands in between. Categories are applied archives -> spools -> settings
    -> kprofiles: archives first because spool usage history references archive_id,
    K-profiles last because they leave the database and publish over MQTT.

    Restores never reuse the backup's primary keys. spool.id and print_archives.id
    are bare autoincrement columns, so ids from an old backup very likely belong to
    unrelated rows today; rows are matched on natural keys (tag_uid, then
    tray_uuid, then a descriptive composite for spools; content_hash or filename
    plus started_at for archives), inserted without an explicit id, and an
    old_id -> new_id map rewrites the foreign keys in spool usage history.
    created_at is carried across on insert so restoring the same backup twice
    matches instead of duplicating. Dangling printer/project links are cleared and
    reported rather than failing the row.

    Settings restore re-applies the collector's credential denylist on the read
    side, plus a pattern guard, because a backup taken before that denylist existed
    can still contain secrets. Restored archives are metadata-only: the 3MF and
    thumbnail bytes are not in a Git backup and print_archives.file_path is NOT
    NULL, so inserted rows get an empty path and the UI says so.

    Backup and restore take a mutex against each other; both write the same tables
    and talk to the same printers. Restores are logged as GitHubBackupLog rows with
    trigger="restore", which needs no migration and surfaces them in the existing
    History card.

    Cloud profiles are deliberately not a restore category. The collector never
    actually writes cloud_profiles/*.json - it reads a "setting" list key the Bambu
    Cloud API does not return - and the preset list it would write carries no
    setting payload. Filed separately.

    Permission github:restore already existed and is granted to Administrators, so
    no permission changes were needed.

    Tests: 125 new backend tests (provider reads across all four providers, the
    per-category appliers, the API endpoints) and 13 frontend tests. Full suites
    pass with no regressions; the 35 backend failures on Windows are byte-identical
    with and without this branch.
2026-08-15 14:10:10 +02:00
maziggy b0aafb8d26 Scale the printer card's body text and icons with its size (#1848)
Switching a card from M to XL made it wider, enlarged the printer name
    and the thumbnail, and left everything else where it was. The AMS slot
    labels, temperatures, filament names, status text and every small button
    stayed pinned between 8 and 11 pixels -- under the smallest size used
    anywhere else in the app -- so a full-width card carried the same tiny
    text as the compact one. Browser zoom does not answer this: it enlarges
    the whole page and so preserves the very disparity being reported.

    The card root now carries ten custom properties derived from cardSize,
    and the 200 fixed sizes in its subtree reference them: text-[10px]
    becomes text-[length:var(--pc-t10,10px)], w-3 h-3 becomes
    w-[var(--pc-i3,0.75rem)]. L draws the body 20% larger and XL 40%,
    icons included, so the controls grow with the text instead of staying
    fiddly to hit.

    Custom properties rather than an em-based root font-size. Setting
    font-size on the card would silently reshape any text that declares no
    size of its own, and would break for portalled content. Each converted
    class names its old fixed value as the fallback, so anything rendering
    outside a card root is untouched -- which is what leaves the portalled
    temperature popover exactly as it is. Its four sites stay fixed on
    purpose, as does the page chrome; the conversion was scoped from the
    function declarations rather than line numbers, and afterwards only
    those four intended sites still hold a literal px value.

    S and M stay at 1.0. S is the dense fleet view where density is the
    point and M is the default, so an existing install looks identical until
    the user reaches for a size that is already asking for more room -- the
    same control the request asked this to follow.

    The AMS-HT card needed separate work, because its temperature and
    humidity readings sit beside the slot rather than under it. That single
    slot was the only growable item on its row, so it took every spare pixel
    and pushed the readings hard against the card's edge; it is now capped
    at roughly two ordinary slots, which keeps them clear at any card width.
    The card itself is capped at one full AMS card's width, so a unit that
    wraps onto a line of its own no longer stretches that slot across the
    whole card.

    The AMS slot minimums are deliberately NOT scaled. Raising them was
    tried and reverted: those cards already grow to fill their row, so
    3.5rem is a floor they sit well above, and raising it only cost a unit
    its place on the row -- which is what pushed the AMS-HT onto a line by
    itself and exposed the stretching above. A test pins them at 3.5rem at
    XL so this reads as a decision rather than a missed spot.
2026-08-15 14:09:20 +02:00
maziggy 751bf8d765 Scale the printer card's body text and icons with its size (#1848)
Switching a card from M to XL made it wider, enlarged the printer name
    and the thumbnail, and left everything else where it was. The AMS slot
    labels, temperatures, filament names, status text and every small button
    stayed pinned between 8 and 11 pixels -- under the smallest size used
    anywhere else in the app -- so a full-width card carried the same tiny
    text as the compact one. Browser zoom does not answer this: it enlarges
    the whole page and so preserves the very disparity being reported.

    The card root now carries ten custom properties derived from cardSize,
    and the 200 fixed sizes in its subtree reference them: text-[10px]
    becomes text-[length:var(--pc-t10,10px)], w-3 h-3 becomes
    w-[var(--pc-i3,0.75rem)]. L draws the body 20% larger and XL 40%,
    icons included, so the controls grow with the text instead of staying
    fiddly to hit.

    Custom properties rather than an em-based root font-size. Setting
    font-size on the card would silently reshape any text that declares no
    size of its own, and would break for portalled content. Each converted
    class names its old fixed value as the fallback, so anything rendering
    outside a card root is untouched -- which is what leaves the portalled
    temperature popover exactly as it is. Its four sites stay fixed on
    purpose, as does the page chrome; the conversion was scoped from the
    function declarations rather than line numbers, and afterwards only
    those four intended sites still hold a literal px value.

    S and M stay at 1.0. S is the dense fleet view where density is the
    point and M is the default, so an existing install looks identical until
    the user reaches for a size that is already asking for more room -- the
    same control the request asked this to follow.

    Wiki notes the scaling in the card-size table and why it differs from
    browser zoom. Tests pin the variable values at every size, including
    that S and M still emit the pre-change sizes.
2026-08-15 14:08:58 +02:00
maziggy 4c98979d64 Let the external spool be hidden from the printer card (#1782)
An external spool holder that never gets used still takes a full card's
    width in the Filaments row, next to the AMS units that are actually in
    use. An eye icon at the right-hand end of that row's header now hides
    it, and clicking it again brings it back -- the affordance stays in
    place rather than moving to a settings page, so the choice is
    discoverable and reversible where it applies.

    Per printer rather than global. A global flag would suit a toolbar
    button, but an icon on the card that silently rearranged every other
    card would surprise; it is keyed by printer id in one localStorage
    entry, the same shape as printerCollapsedSections, and sits alongside
    the other browser-local printer-page view preferences.

    The toggle is offered only when the printer has at least one AMS. On a
    machine with no AMS the external spool is the entire filament section,
    so hiding it would leave an empty row with no control to undo it. The
    icon and the hide condition read the same canHideExternalSpool, so a
    preference stored before an AMS was unplugged cannot blank the row
    either -- the spool reappears instead.

    The store lives in a new utils/printerCardPrefs.ts rather than in the
    9,157-line page. It re-reads before writing so two cards toggled in one
    session cannot clobber each other's entry, deletes the key instead of
    storing false, and treats a malformed or unavailable localStorage as
    "nothing hidden" so a private-mode browser cannot throw out of a render.
2026-08-15 14:08:14 +02:00
maziggy 4ecfd9ab4f Hold error and warning toasts for twice as long
Every pop-up notification auto-dismissed after three seconds regardless
    of what it said. That suits "Settings saved" -- a confirmation of
    something the user just did, skimmed rather than read -- but errors and
    warnings are a different kind of message. They carry a reason, often one
    relayed from the printer or the backend, and they run to a couple of
    lines. Three seconds was not enough to finish reading one, and there is
    no notification history to go back to once it slides away.

    Errors and warnings now hold for six seconds; success and info keep the
    three-second default. The duration was a bare literal in showToast and
    is now derived from the toast type, with the long window expressed as
    twice the base so the two cannot drift apart if the base is retuned.

    showPersistentToast never had an auto-dismiss timer and is untouched, as
    is the background dispatch toast -- its timer measures "the summary has
    stopped changing" rather than reading time. Manual dismissal is
    unchanged for every type.
2026-08-15 14:07:55 +02:00
maziggy 190d4f2ce8 Add temperatures to the streaming overlay and a URL builder (#1422)
The overlay at /overlay/{printer} draws live print data over a
    full-screen camera view for OBS, a wall display or any browser source.
    It has been tunable since it shipped -- which fields, what size, what
    frame rate -- but only through query parameters documented in the wiki,
    and temperatures were not among the fields on offer. The request asked
    for temperatures first and for the field set to be selectable in the web
    UI; this addresses both.

    Nozzle, bed and chamber readings join the list. The target is drawn only
    while the heater is still climbing, so a settled hotend reads "220°C"
    for the rest of the print instead of the noisier "220 / 220°C" -- 219.6
    against a target of 220 rounds to the same number, and repeating it says
    nothing. Both nozzles appear on a dual-nozzle machine. They are drawn
    whether or not a print is running, because a preheating printer is
    exactly when they are worth watching, and each reading appears only when
    the printer genuinely reports one: chamber temperature stays absent on
    P1 and A1 models, which publish a chamber_temper with no sensor behind
    it, so the overlay never puts a measurement on screen that does not
    exist. Labels reuse the heater chart's strings rather than inventing a
    second vocabulary for the same three things.

    The feed sends an allow-list rather than the temperatures dict. That
    dict doubles as the MQTT client's working memory -- derived heater flags
    and private target-set timestamps live alongside the readings -- and an
    overlay token is a narrower grant than a login, so it gets exactly what
    the overlay draws and does not pick up fields as the dict grows. The
    same chamber-sensor gate the full status payload already applies is
    applied here. The integration test that asserts the payload's exact key
    set, which exists to catch that surface widening silently, is updated
    deliberately.

    Temperatures are not in the default field set, so an overlay URL already
    pasted into a scene renders identically after upgrading.

    Settings -> API Keys -> Streaming Overlay now builds the URL: printer,
    field checkboxes, size, frame rate, camera toggle, an optional token,
    and a copy button. It persists nothing and calls nothing new -- the URL
    is the configuration, which keeps a scene reproducible by copy-paste and
    lets two displays show different fields off one token. Fields are
    emitted in the overlay's own top-to-bottom order rather than click
    order, and parameters left at their default are omitted, so the same
    selection always produces the same URL. The preview alongside it stays
    off until asked for: an always-live iframe would hold a subscriber on
    the printer's single camera connection for as long as the settings tab
    stayed open.

    The preview needed one narrow security-header change. Every SPA route
    sent frame-ancestors 'none', which is stricter than the SAMEORIGIN in
    X-Frame-Options beside it and refuses even a same-origin frame, so the
    preview showed Firefox's "another site has embedded it" page instead of
    the overlay. The overlay path now sends 'self', mirroring /gcode-viewer,
    which admits a framer only on this origin -- Bambuddy's own UI. Every
    other path keeps 'none', and embedding the overlay from another host
    still requires TRUSTED_FRAME_ORIGINS.
2026-08-15 14:07:27 +02:00
maziggy 268940573d Stop a drying cycle reporting itself finished a minute in (#2759)
Starting the dryer on an AMS 2 Pro holding two PETG and two PLA spools
    and picking PLA showed "PLA @ 45°C" for about a minute and then switched
    to "PETG @ 65°C" for the remaining twelve hours.

    Bambu never echoes back which filament or temperature a cycle is
    running, so the badge reads the target cached when the command went out,
    and that cache had been dropped. Between accepting the command and
    settling its countdown the firmware publishes one update with the
    remaining time at zero while the unit is still in its Checking phase --
    the reporter's log has 720, then 0, then 719, and four seconds later the
    same unit's info hex decodes to dry_status 2, Drying. The falling-edge
    detector read that zero as the cycle ending. Losing the cached target
    left the badge to guess from the first loaded slot, which was PETG, and
    its RFID-recommended 65°C. The same false ending fired
    on_drying_complete, so anyone with smart-plug auto-off-after-drying
    switched on had power scheduled to cut one minute into a twelve-hour
    dry; the reporter had it off, which is the only reason this reads as a
    cosmetic bug.

    A remaining time of zero now ends a cycle only when the unit is not also
    reporting an active phase. dry_status comes from the same info hex
    already parsed a few lines above, so this costs nothing to check.
    Stopping and Error are deliberately not treated as active -- those
    should end it -- and a unit that reports no phase at all still ends its
    cycles, so the gate can only ever suppress on positive evidence that the
    cycle is live. A suppressed edge leaves the remembered dry_time alone,
    exactly as the #1462 absent-value skip does, so the push that really
    ends the cycle still sees a non-zero previous.

    The fallback guess is tightened to match. On a mixed unit the first tray
    is evidence of nothing, and naming a temperature the cycle is not using
    is worse than naming none, so it now answers only when every loaded
    spool is the same filament and otherwise leaves the badge showing the
    countdown alone. Both the websocket and REST status builders carried
    their own copy of that loop; they now share one helper, which also takes
    the temperature from the first slot that carries an RFID one rather than
    giving up when slot 1 holds a third-party spool.
2026-08-15 14:07:01 +02:00
maziggy 1136ce33ab Add CAP_NET_BIND_SERVICE everywhere the service is defined (#2549)
The Virtual Printer binds 990 and 322, below 1024, which a service running
    as a normal user may not do without CAP_NET_BIND_SERVICE. Without it the
    rest of Bambuddy works and only the VP is dead -- sockets never open, the
    slicer never finds the printer, and the sole trace is one journal line.

    332a7c6ac added the line to install/install.sh in March under the heading
    "Fix install.sh missing AmbientCapabilities". Three other places define the
    same unit and none of them got it: the manual template, the combined
    Bambuddy + SpoolBuddy installer, and the unit the wiki tells you to paste.
    The wiki additionally claimed the capability was always included.

    Also diagnose it. The VP diagnostic reported only that nothing was listening
    on 990, which reads identically to a port conflict. It now checks CapEff for
    the capability and names it as the cause -- but stays quiet when the port is
    answering (an iptables REDIRECT is the documented alternative and that host
    works) and when the capability is held (the port is down for another reason
    and blaming this would misdirect). Skips where there is no procfs rather
    than putting a systemd instruction in front of a macOS user.
2026-08-15 14:06:08 +02:00
maziggy bd0b221cb9 Keep live updates flowing while the tab is in the background (#2754)
Printer status, query invalidations and the message queue all ran their
    work inside requestAnimationFrame. A hidden tab gets no rendering
    opportunities, so the browser holds those callbacks instead of merely
    throttling them: the socket stayed open, messages kept arriving, and
    every cache write parked in a pending frame until the tab was shown
    again — at which point they all ran at once. The tab-title progress
    reads ['printerStatus', id] and nothing else, so it simply froze.

    The frames came in with the print-completion freeze fix, where the
    load-bearing part was the coalescing (100ms throttle, 3s debounce,
    500ms stagger). That is untouched; the frames only deferred each write
    by ~16ms and are gone. Not made visibility-aware on purpose — a frame
    scheduled just before hiding would fire after the writes that took the
    hidden path and clobber newer status with older.

    The six rAF stubs in the tests ran frames synchronously, which is why
    nothing caught this. Replaced with coverage that stubs rAF to never
    fire, as a hidden tab does.
2026-08-15 14:05:24 +02:00
jmoore-skild bbd991510f fix(backup): refuse prometheus_enabled when the backup has no token either (#2656)
The companion-credential rule has five conditions, and the second one -- "the
backup itself carried a usable credential" -- was applied to all five pairs. It
should not be. It is what stops the rule over-refusing an anonymous MQTT broker
or an anonymous LDAP bind, both of which are working configs: there, an empty
credential in the backup means the restore is not producing anything weaker
than what was backed up.

For prometheus_enabled it does not transfer. An empty prometheus_token removes
/api/v1/metrics' only gate, so the exposure is a property of the toggle, not of
a downgrade relative to the backup -- and prometheus_token is optional, so a
backup taken on an instance that enabled Prometheus without ever setting one
carries the toggle and no usable token. That payload skipped the refusal
entirely: not a candidate, so the local-state pass never ran, and the blocklist
quietly dropped the token key. On a token-less target the result was
prometheus_enabled=true, no token row anywhere, and a full unauthenticated
metrics dump -- the same hole the rule was written to close, reached from the
likelier of the two directions.

So condition 2 is now per-pair: an exposure class (prometheus) that skips it and
is judged on local state alone, and an availability class (mqtt, ldap, ha,
virtual_printer) that keeps it. Nothing else changes -- the local-state pass
already stands down when the instance has its own credential, when HA_TOKEN is
in the environment, and when the toggle is already on locally, so "the exposure
pre-dates this restore" still holds and refusals still get no tally increment.

One wording consequence: an exposure toggle can now be refused on a payload with
no credential-like key in it at all, where the shared caveat would have read "0
credential-like key(s) will be skipped". That case gets its own preview detail
code, settingsCompanionOnlyWillSkip, in all 13 locales.

Tests: 6 that fail pre-fix -- the token key absent and blank at the unit level,
the new preview wording, and the integration test through the real endpoint for
both payloads (200 with a full metrics body before this, 404 after). Plus 3
controls, because over-refusal is still the real risk: the exposure route must
still stand down for a local token and for an already-on toggle, and the
availability class must still let a credential-less mqtt/ldap/virtual_printer
toggle through. The anonymous-broker and anonymous-bind controls are unchanged
and still pass.
2026-08-04 08:57:34 -04:00
jmoore-skild 945f50a1ab fix(backup): stop overwrite writing a spool's other tag key (#2656)
Four of the review's smaller items.

E6, the substantive one. tag_uid and tray_uuid are both in the overwrite setattr
loop, so a spool matched on one key got the backup's *other* key written onto it.
Neither column has a unique constraint (models/spool.py, and no unique index in
the migrations), so nothing errors — a duplicate tag simply appears, after which
_find_spool's .scalars().first() is non-deterministic and an AMS tag lookup
resolves to an arbitrary one of the two spools. The same loop could also clear a
tag the user had scanned since the backup was taken, when the backup entry held
None.

_find_spool now reports which key matched, and _guard_tag_overwrite drops a tag
column from the write when the incoming value is empty and the local row has one
(the backup predates the scan, so the local tag is the newer fact) or when
another local spool already holds it. Announced in the tally the way the archive
un-delete case already announces itself, rather than done silently — the
spoolTagKept locale key landed with the rest of the i18n block last commit.

E5. The Restore button is hidden without github:restore. All three endpoints are
gated on it server-side, so the modal 403s on its first preview; offering the
button is offering an action that cannot work. Button only — the card stays
visible, since configuring backups is a separate permission — and hasPermission
returns true with auth off, so a single-user instance is unaffected.

E3. models/github_backup.py: the trigger comment said manual/scheduled; this PR
added a third value.

E4. ha_token_from_env: recommending no change, with the reasoning recorded as a
test rather than left in a review thread. It is built only in the settings GET
response, is absent from AppSettingsUpdate, and is therefore never a Settings
row — it cannot reach a backup, so an allowlist entry would be dead code. Worse,
a name-shaped exception to a belt-and-braces denylist is a live hole: an
attacker-authored settings/app_settings.json could get a *token*-named row
written by choosing that name.

4 unit tests and 1 frontend test that fail against this commit's parent, plus 4
controls: a free tag is still written, an unchanged tag is not reported as kept,
an insert is unaffected, and the button still shows with auth disabled.
2026-08-04 08:57:34 -04:00
jmoore-skild af8d14d796 i18n(backup): make the restore notes and preview details translatable (#2656)
A German user got a translated modal with "Not present in this backup commit" in
the middle of it. Every tally note and preview caveat was a server-built English
sentence rendered verbatim.

Follows the backup.pathCheck contract already in use one card down in the same
component, deliberately rather than inventing a second convention: the server
sends a `code` plus typed `params` and carries the English along as the
fallback, and the client renders
`t(`...${code}`, { ...params, defaultValue: message })`. The defaultValue arm is
what keeps a newer backend's unfamiliar code readable instead of printing the
raw key — covered by its own test.

Shapes:
- notes: list[str] -> list[GitHubRestoreNote] {code, params, message}. Breaking,
  but the field is unreleased in this same PR.
- GitHubRestorePreviewCategory gains detail_code / detail_params; `detail` stays
  as the English fallback.
- _CategoryTally.note(code, message, **params), deduped on (code, params) rather
  than on the rendered text, so two offline printers both keep their names. The
  20-note cap is unchanged.

28 new leaves across 13 locales: 20 notes.* and 8 details.*. `noData` collapses
the four per-category "No X data in this backup" strings into one, since the
category heading already renders beside it. Counts use single-form {{count}} in
the existing "N record(s)" style rather than i18next plural suffixes — nothing in
this block uses _one/_other and the parity script has extra rules for them.
Parity holds at 5737 leaves in all 13 locales.

Deliberately out of scope, and worth saying so rather than leaving it to look
like an oversight: result.message, the commit-picker subject lines and the HTTP
error strings stay English. Those also originate in the provider backends, so
code-ifying them widens the diff well past the restore service.

spoolTagKept is added here with the rest of the locale churn but is not emitted
until the next commit, so the 13-locale change lands once.
2026-08-04 08:57:33 -04:00
jmoore-skild 6ea2daf9d0 i18n(backup): rename the Git restore button to "Restore from Git" (#2656)
Two buttons labelled "Restore" were visible in the same viewport — this card's
and the Local Backup card's — with different destinations. The modal title
disambiguated them; the buttons did not.

backup.restoreFromGit.button only. The Local Backup card's t('backup.restore') is
unchanged, and each locale's new value follows the wording that locale already
uses in the modal title rather than being a literal translation of the English.

One correction to the review's aside: this does not simplify
GitHubRestoreModal.test.tsx. The /Restore$/ + confirmButtons[length - 1] idiom
there is not caused by the card button — the modal is rendered standalone in
those tests — but by the modal's own footer action and its confirm dialog both
reading t('backup.restore'). Left alone as out of scope.
2026-08-04 08:57:33 -04:00
jmoore-skild 07244b6a43 fix(backup): don't count a stale selection, and say when archive links are dropped (#2656)
Two smaller restore-path fixes from the same review.

The modal's footer counted `selected` raw while the checkboxes rendered
`selected && isAvailable`. Switching commits keeps `selected` on purpose (it is
only pruned once the new preview lands), so for as long as the new commit's
preview was in flight — with the category list replaced by its spinner — the
footer still read "2 selected" over an enabled Restore button, and clicking it
restored the newly-picked commit with the previous commit's categories, none of
which the user had seen an item count for. The count and the POST body now come
from one `selectedCategories` memo gated on availability, exactly as the
checkboxes are, so both go empty until the preview lands.

Restoring Spool inventory without Print archives leaves archive_id_map empty,
so every usage -> archive link is nulled even where the archive exists locally.
It can't be resolved here (the archives payload isn't fetched for a category
that wasn't selected) and a later archives-only restore won't repair it either,
since the usage dedupe key doesn't include archive_id and those rows read as
already-present. So it gets a note naming the remedy while the user can still
redo the run with both categories ticked.

Carries the rebuilt bundle (index-C2LOlVCR.js -> index-C16HJNOV.js).
2026-08-04 08:22:58 -04:00
jmoore-skild 5677f4495f i18n(backup): add the Ukrainian strings for the restore modal (#2656)
The Ukrainian locale landed on dev in #2695, after this branch was cut,
so uk.ts was missing the 23 restoreFromGit keys the rest of this branch
adds to the other twelve. check-i18n-parity.mjs auto-discovers the
locales directory, so it would have failed on the merge.

All 13 locales are now at parity (5699 leaves each). Terminology follows
what uk.ts already uses for the local-backup restore path — "Відновити"
for restore, "резервну копію" for backup.
2026-08-04 08:22:57 -04:00
jmoore-skild 208170b03a fix(backup): don't let SettingsPage revert a restored settings category (#2656)
Restoring App Settings from the Backup tab silently reverted almost everything
it reported restoring.

GitHubRestoreModal invalidated ['settings'] on success. SettingsPage — which
renders the modal — keeps a `localSettings` copy of its form state alongside a
debounced effect that PATCHes it back whenever the server copy differs. The
refetch made the two differ, the effect cannot tell "server changed" from "user
edited", and 500 ms later it wrote the pre-restore values back over the restore.
75 of the ~80 keys in a backup sit in that save payload, so a restore reporting
"77 restored, 0 failed" left only the five auth/internal flags behind. The
backend was correct throughout: the same restore driven against the API with no
browser open applies cleanly.

Since the Restore button lives on the Settings page, this was the default path
rather than an edge case.

Fixed inside the modal rather than in SettingsPage: that debounce's own comments
show it was tuned to avoid resetting text fields mid-typing, and widening this
change into it risks that. So ['settings'] is no longer invalidated, and every
exit path (footer Close, header X, overlay click, Escape) now reloads instead of
closing when settings were among the restored categories, since leaving the page
mounted is what arms the overwrite. The ['spools'] and ['archives']
invalidations are unchanged — SettingsPage is the only page carrying this kind
of whole-payload auto-save.

The root cause is left for a follow-up: any future feature that writes settings
server-side will be reverted the same way.

Found by manual end-to-end testing against a private test repo, which also
confirmed the natural-key id remapping and the overwrite-off behaviour working
as designed.

Tests: 3 new frontend tests — the settings query is never invalidated, a
settings restore reloads rather than closing, and a non-settings restore still
closes normally. The first two fail against the pre-fix component. Full frontend
suite green (186 files / 2459 tests), i18n parity unchanged, eslint clean.
2026-08-04 08:22:57 -04:00
jmoore-skild 6a239314dc feat(backup): restore selected categories from a Git backup commit (#2656)
The Git backup feature was push-only: there was no equivalent of the local
backup's Restore button, so recovering meant hand-downloading JSON files from
the repository. This adds the read side.

Providers gain list_commits / list_tree / fetch_files on the GitProviderBackend
ABC. GitHub implements them against the Git Data API and Gitea/Forgejo inherit
that unchanged; GitLab overrides for its own REST shape, including tree
pagination and subgroup path encoding. fetch_files is batched so the path ->
blob SHA lookup happens once per restore rather than once per file, and uses the
blobs API rather than contents because contents silently inlines only the first
1 MB.

The new GitHubRestoreService resolves HEAD to a concrete SHA up front, so a
preview and the restore that follows act on the same commit even if a scheduled
backup lands in between. Categories are applied archives -> spools -> settings
-> kprofiles: archives first because spool usage history references archive_id,
K-profiles last because they leave the database and publish over MQTT.

Restores never reuse the backup's primary keys. spool.id and print_archives.id
are bare autoincrement columns, so ids from an old backup very likely belong to
unrelated rows today; rows are matched on natural keys (tag_uid, then
tray_uuid, then a descriptive composite for spools; content_hash or filename
plus started_at for archives), inserted without an explicit id, and an
old_id -> new_id map rewrites the foreign keys in spool usage history.
created_at is carried across on insert so restoring the same backup twice
matches instead of duplicating. Dangling printer/project links are cleared and
reported rather than failing the row.

Settings restore re-applies the collector's credential denylist on the read
side, plus a pattern guard, because a backup taken before that denylist existed
can still contain secrets. Restored archives are metadata-only: the 3MF and
thumbnail bytes are not in a Git backup and print_archives.file_path is NOT
NULL, so inserted rows get an empty path and the UI says so.

Backup and restore take a mutex against each other; both write the same tables
and talk to the same printers. Restores are logged as GitHubBackupLog rows with
trigger="restore", which needs no migration and surfaces them in the existing
History card.

Cloud profiles are deliberately not a restore category. The collector never
actually writes cloud_profiles/*.json - it reads a "setting" list key the Bambu
Cloud API does not return - and the preset list it would write carries no
setting payload. Filed separately.

Permission github:restore already existed and is granted to Administrators, so
no permission changes were needed.

Tests: 125 new backend tests (provider reads across all four providers, the
per-category appliers, the API endpoints) and 13 frontend tests. Full suites
pass with no regressions; the 35 backend failures on Windows are byte-identical
with and without this branch.
2026-08-04 08:22:57 -04:00
maziggy 78cbd82259 Scale the printer card's body text and icons with its size (#1848)
Switching a card from M to XL made it wider, enlarged the printer name
and the thumbnail, and left everything else where it was. The AMS slot
labels, temperatures, filament names, status text and every small button
stayed pinned between 8 and 11 pixels -- under the smallest size used
anywhere else in the app -- so a full-width card carried the same tiny
text as the compact one. Browser zoom does not answer this: it enlarges
the whole page and so preserves the very disparity being reported.

The card root now carries ten custom properties derived from cardSize,
and the 200 fixed sizes in its subtree reference them: text-[10px]
becomes text-[length:var(--pc-t10,10px)], w-3 h-3 becomes
w-[var(--pc-i3,0.75rem)]. L draws the body 20% larger and XL 40%,
icons included, so the controls grow with the text instead of staying
fiddly to hit.

Custom properties rather than an em-based root font-size. Setting
font-size on the card would silently reshape any text that declares no
size of its own, and would break for portalled content. Each converted
class names its old fixed value as the fallback, so anything rendering
outside a card root is untouched -- which is what leaves the portalled
temperature popover exactly as it is. Its four sites stay fixed on
purpose, as does the page chrome; the conversion was scoped from the
function declarations rather than line numbers, and afterwards only
those four intended sites still hold a literal px value.

S and M stay at 1.0. S is the dense fleet view where density is the
point and M is the default, so an existing install looks identical until
the user reaches for a size that is already asking for more room -- the
same control the request asked this to follow.

The AMS-HT card needed separate work, because its temperature and
humidity readings sit beside the slot rather than under it. That single
slot was the only growable item on its row, so it took every spare pixel
and pushed the readings hard against the card's edge; it is now capped
at roughly two ordinary slots, which keeps them clear at any card width.
The card itself is capped at one full AMS card's width, so a unit that
wraps onto a line of its own no longer stretches that slot across the
whole card.

The AMS slot minimums are deliberately NOT scaled. Raising them was
tried and reverted: those cards already grow to fill their row, so
3.5rem is a floor they sit well above, and raising it only cost a unit
its place on the row -- which is what pushed the AMS-HT onto a line by
itself and exposed the stretching above. A test pins them at 3.5rem at
XL so this reads as a decision rather than a missed spot.
2026-08-04 14:09:10 +02:00
maziggy 45b678692c Scale the printer card's body text and icons with its size (#1848)
Switching a card from M to XL made it wider, enlarged the printer name
and the thumbnail, and left everything else where it was. The AMS slot
labels, temperatures, filament names, status text and every small button
stayed pinned between 8 and 11 pixels -- under the smallest size used
anywhere else in the app -- so a full-width card carried the same tiny
text as the compact one. Browser zoom does not answer this: it enlarges
the whole page and so preserves the very disparity being reported.

The card root now carries ten custom properties derived from cardSize,
and the 200 fixed sizes in its subtree reference them: text-[10px]
becomes text-[length:var(--pc-t10,10px)], w-3 h-3 becomes
w-[var(--pc-i3,0.75rem)]. L draws the body 20% larger and XL 40%,
icons included, so the controls grow with the text instead of staying
fiddly to hit.

Custom properties rather than an em-based root font-size. Setting
font-size on the card would silently reshape any text that declares no
size of its own, and would break for portalled content. Each converted
class names its old fixed value as the fallback, so anything rendering
outside a card root is untouched -- which is what leaves the portalled
temperature popover exactly as it is. Its four sites stay fixed on
purpose, as does the page chrome; the conversion was scoped from the
function declarations rather than line numbers, and afterwards only
those four intended sites still hold a literal px value.

S and M stay at 1.0. S is the dense fleet view where density is the
point and M is the default, so an existing install looks identical until
the user reaches for a size that is already asking for more room -- the
same control the request asked this to follow.

Wiki notes the scaling in the card-size table and why it differs from
browser zoom. Tests pin the variable values at every size, including
that S and M still emit the pre-change sizes.
2026-08-04 13:39:54 +02:00
maziggy 3db8ac9da7 Let the external spool be hidden from the printer card (#1782)
An external spool holder that never gets used still takes a full card's
width in the Filaments row, next to the AMS units that are actually in
use. An eye icon at the right-hand end of that row's header now hides
it, and clicking it again brings it back -- the affordance stays in
place rather than moving to a settings page, so the choice is
discoverable and reversible where it applies.

Per printer rather than global. A global flag would suit a toolbar
button, but an icon on the card that silently rearranged every other
card would surprise; it is keyed by printer id in one localStorage
entry, the same shape as printerCollapsedSections, and sits alongside
the other browser-local printer-page view preferences.

The toggle is offered only when the printer has at least one AMS. On a
machine with no AMS the external spool is the entire filament section,
so hiding it would leave an empty row with no control to undo it. The
icon and the hide condition read the same canHideExternalSpool, so a
preference stored before an AMS was unplugged cannot blank the row
either -- the spool reappears instead.

The store lives in a new utils/printerCardPrefs.ts rather than in the
9,157-line page. It re-reads before writing so two cards toggled in one
session cannot clobber each other's entry, deletes the key instead of
storing false, and treats a malformed or unavailable localStorage as
"nothing hidden" so a private-mode browser cannot throw out of a render.
2026-08-04 13:08:30 +02:00
maziggy 9bc96aeb83 Hold error and warning toasts for twice as long
Every pop-up notification auto-dismissed after three seconds regardless
of what it said. That suits "Settings saved" -- a confirmation of
something the user just did, skimmed rather than read -- but errors and
warnings are a different kind of message. They carry a reason, often one
relayed from the printer or the backend, and they run to a couple of
lines. Three seconds was not enough to finish reading one, and there is
no notification history to go back to once it slides away.

Errors and warnings now hold for six seconds; success and info keep the
three-second default. The duration was a bare literal in showToast and
is now derived from the toast type, with the long window expressed as
twice the base so the two cannot drift apart if the base is retuned.

showPersistentToast never had an auto-dismiss timer and is untouched, as
is the background dispatch toast -- its timer measures "the summary has
stopped changing" rather than reading time. Manual dismissal is
unchanged for every type.
2026-08-04 12:54:24 +02:00
maziggy 33ab5f1ead Add temperatures to the streaming overlay and a URL builder (#1422)
The overlay at /overlay/{printer} draws live print data over a
full-screen camera view for OBS, a wall display or any browser source.
It has been tunable since it shipped -- which fields, what size, what
frame rate -- but only through query parameters documented in the wiki,
and temperatures were not among the fields on offer. The request asked
for temperatures first and for the field set to be selectable in the web
UI; this addresses both.

Nozzle, bed and chamber readings join the list. The target is drawn only
while the heater is still climbing, so a settled hotend reads "220°C"
for the rest of the print instead of the noisier "220 / 220°C" -- 219.6
against a target of 220 rounds to the same number, and repeating it says
nothing. Both nozzles appear on a dual-nozzle machine. They are drawn
whether or not a print is running, because a preheating printer is
exactly when they are worth watching, and each reading appears only when
the printer genuinely reports one: chamber temperature stays absent on
P1 and A1 models, which publish a chamber_temper with no sensor behind
it, so the overlay never puts a measurement on screen that does not
exist. Labels reuse the heater chart's strings rather than inventing a
second vocabulary for the same three things.

The feed sends an allow-list rather than the temperatures dict. That
dict doubles as the MQTT client's working memory -- derived heater flags
and private target-set timestamps live alongside the readings -- and an
overlay token is a narrower grant than a login, so it gets exactly what
the overlay draws and does not pick up fields as the dict grows. The
same chamber-sensor gate the full status payload already applies is
applied here. The integration test that asserts the payload's exact key
set, which exists to catch that surface widening silently, is updated
deliberately.

Temperatures are not in the default field set, so an overlay URL already
pasted into a scene renders identically after upgrading.

Settings -> API Keys -> Streaming Overlay now builds the URL: printer,
field checkboxes, size, frame rate, camera toggle, an optional token,
and a copy button. It persists nothing and calls nothing new -- the URL
is the configuration, which keeps a scene reproducible by copy-paste and
lets two displays show different fields off one token. Fields are
emitted in the overlay's own top-to-bottom order rather than click
order, and parameters left at their default are omitted, so the same
selection always produces the same URL. The preview alongside it stays
off until asked for: an always-live iframe would hold a subscriber on
the printer's single camera connection for as long as the settings tab
stayed open.

The preview needed one narrow security-header change. Every SPA route
sent frame-ancestors 'none', which is stricter than the SAMEORIGIN in
X-Frame-Options beside it and refuses even a same-origin frame, so the
preview showed Firefox's "another site has embedded it" page instead of
the overlay. The overlay path now sends 'self', mirroring /gcode-viewer,
which admits a framer only on this origin -- Bambuddy's own UI. Every
other path keeps 'none', and embedding the overlay from another host
still requires TRUSTED_FRAME_ORIGINS.
2026-08-04 12:38:36 +02:00
maziggy 71a06f3638 Add batch orders with a quantity per plate (#342)
Printing a multi-plate file in different quantities per plate meant
queueing each plate separately and tracking the counts by hand: one
shared Quantity field cannot say "plate 1 once, plate 2 twice, plate 3
three times". Each selected plate now carries its own quantity, and the
submission becomes an order on a new Batches tab.

The point is the distinction the old flat batch could not express.
print_batch_plates stores how many runs of each plate were wanted,
separately from what was queued, so a run that fails, is cancelled or is
skipped does not satisfy a target -- the order goes on saying it owes a
print instead of quietly under-delivering. Queue remaining re-queues
exactly what is missing, for the whole order or one plate, by cloning
the most recent item for that plate: that inherits the printer or model
target, AMS mapping, filament overrides and print options along with the
validation they already passed, rather than re-serialising twenty fields
through a template that would drift from the model the first time
someone adds a column. Clones append to the end of the relevant
printer's queue and take the same advisory lock the add-to-queue route
does; positions are per-printer sequences, not global.

Cost is measured, not estimated. print_log_entries gains queue_item_id,
set where the queue item is already in scope, so each run's material and
energy are attributed through the item that produced them -- an
unrelated reprint of the same archive never lands in an order's total,
and a multi-plate order gets each plate's own cost rather than the whole
file's via the plate-scoped estimate from #2614. Before any run has
completed there is no honest figure, so cost reads as unknown instead of
a fabricated 0.00.

The Batches tab wires up GET /queue/batches, which has been unreferenced
since the batch MVP shipped, along with six locale keys that were
translated and never used. It is a separate tab because an order
outlives the queue that produced it: once its runs finish they leave the
active queue, so Queue and History each hold half the picture.

completed was not a reachable status before now, so every batch created
since April is still marked active however long ago its last print
finished -- 73 of them on the development install. A startup pass closes
out the finished ones: those whose runs all completed become completed,
and groupings whose items were all cancelled become cancelled, which is
what they are. Not applied to orders, which state their intent
independently of their runs and still owe the work. Only batches with
nothing queued or printing are considered, and repeating the pass also
catches an order whose last run landed while the process was down.
Batches with neither items nor targets are no longer listed at all --
empty shells left when a grouping's items went with their source
archive.

Dispatch applies the same source-file gates as POST /queue/. It creates
queue items, so without them it would be a weaker door to the same
outcome; the archive and library-file checks move into shared helpers
so a third route cannot drift from them.
2026-08-04 11:11:36 +02:00
maziggy 28a6ca6f4d Add CAP_NET_BIND_SERVICE everywhere the service is defined (#2549)
The Virtual Printer binds 990 and 322, below 1024, which a service running
as a normal user may not do without CAP_NET_BIND_SERVICE. Without it the
rest of Bambuddy works and only the VP is dead -- sockets never open, the
slicer never finds the printer, and the sole trace is one journal line.

332a7c6ac added the line to install/install.sh in March under the heading
"Fix install.sh missing AmbientCapabilities". Three other places define the
same unit and none of them got it: the manual template, the combined
Bambuddy + SpoolBuddy installer, and the unit the wiki tells you to paste.
The wiki additionally claimed the capability was always included.

Also diagnose it. The VP diagnostic reported only that nothing was listening
on 990, which reads identically to a port conflict. It now checks CapEff for
the capability and names it as the cause -- but stays quiet when the port is
answering (an iptables REDIRECT is the documented alternative and that host
works) and when the capability is held (the port is down for another reason
and blaming this would misdirect). Skips where there is no procfs rather
than putting a systemd instruction in front of a macOS user.
2026-08-04 08:33:30 +02:00
maziggy c28e053126 Keep live updates flowing while the tab is in the background (#2754)
Printer status, query invalidations and the message queue all ran their
work inside requestAnimationFrame. A hidden tab gets no rendering
opportunities, so the browser holds those callbacks instead of merely
throttling them: the socket stayed open, messages kept arriving, and
every cache write parked in a pending frame until the tab was shown
again — at which point they all ran at once. The tab-title progress
reads ['printerStatus', id] and nothing else, so it simply froze.

The frames came in with the print-completion freeze fix, where the
load-bearing part was the coalescing (100ms throttle, 3s debounce,
500ms stagger). That is untouched; the frames only deferred each write
by ~16ms and are gone. Not made visibility-aware on purpose — a frame
scheduled just before hiding would fire after the writes that took the
hidden path and clobber newer status with older.

The six rAF stubs in the tests ran frames synchronously, which is why
nothing caught this. Replaced with coverage that stubs rAF to never
fire, as a hidden tab does.
2026-08-04 07:56:27 +02:00
Pascal 781acfd1cb Always show slice option in filemanager context menu but fallback to local slicer 2026-08-03 23:45:41 +02:00
Pascal eda2e04599 Added option to open/slice files in local slicers when slicer api is enabled 2026-08-03 23:41:22 +02:00
maziggy 8dde48587e Move the bug-report trigger out of the contended corner (#2750)
The floating disc is pinned bottom-right, which is where most controls
live — it covered ~83% of the Profiles scroll-to-top button at the same
z-index, and being viewport-fixed it also sits on card action buttons
that scroll under it. Below the sidebar-compact breakpoint the trigger
moves into the top bar; at 1144px and up nothing changes.

Not a hide switch: the bubble is the only entry to the report form, and
that form runs the printer diagnostic, the log scan and the debug
capture. Hiding it yields reports with nothing attached.

The panel stays at the Layout root — the header is a fixed z-40
stacking context and would bury a nested z-50 panel under every modal.
Also fixes the panel hanging 16px off-screen on phones: w-full resolves
against the viewport, so right-4 pushed its left edge negative.
2026-08-03 15:44:10 +02:00
maziggy dbf674561c Sort the inventory by colour, not colour name (#2729)
The Color column was missing from the page's sort-extractor map, so its
header ignored clicks. Sorts by family first — rainbow, then browns,
then neutrals light to dark — with the hue sort running inside each.

The issue asked for a straight hue/saturation/lightness sort. Measured
against a real 30-spool inventory that puts Titan Gray (hue 210, sat
0.04) among the blues and a warm grey next to the reds, and splits the
oranges around brown. Neutrals order by lightness because their hue is
noise.

Families come from the classifier that already names colours missing
from the catalog, so the Color and Color Name columns cannot disagree.
2026-08-03 15:24:37 +02:00
maziggy 689f5276e4 Show the compose directory in the Docker update command (#2664)
The printed command only works from the directory holding the compose
file, which is the thing the user came to the page not knowing. Adds a
copy button, a saved Compose directory setting, BAMBUDDY_COMPOSE_DIR,
and best-effort detection from a bind mount's host path.

Compose records the directory on every container it creates, but reading
that label needs the Docker socket mounted in — root-equivalent access
for a convenience string. The mountinfo guess is a prefill only: its root
field is relative to the mounted device, so a compose dir on its own
mount loses that prefix, and nothing in the container can detect it.

The field is restricted to path characters. It is the one setting whose
purpose is to be pasted into a root shell, so "/opt/bambuddy; rm -rf /"
would otherwise render as a plausible update command.
2026-08-03 15:12:02 +02:00
maziggy a08d3e62f3 Show the Print Log's per-run cost and energy, and let users pick columns (#2636)
The list and update endpoints serialised field by field and never named
cost / energy_kwh / energy_cost, so values Bambuddy had been recording
all along went out as nulls. Both now validate from the ORM row, which
removes the chance to omit a field rather than patching the three that
were missing.

Adds a Filament Used column plus a Columns picker for Cost, Energy,
Energy Cost and Finished, persisted per browser.

Also fixes the log view being unreachable with zero archives: the empty
state ran before the view check, hiding a log that outlives the archives
it refers to.

---

Sort the Print Log by any column (#2636)

Adds sort_by / sort_dir to the print-log endpoint, driven by clickable
column headers. Server-side because paging is: ordering the rows the
client holds would sort one page rather than the log.

Empty values are held last in both directions — Postgres sorts NULLs
high and SQLite low, so the same click would otherwise open on blanks
on one backend and values on the other. id DESC breaks ties so paging
through a low-cardinality sort can't repeat or skip a row.
2026-08-03 14:53:21 +02:00
maziggy e95c42c021 Add auto-orient and auto-arrange to server-side slicing (#2548)
Both are per-slice checkboxes, off by default, forwarded as the sidecar's
orient / arrange form fields. An unticked box is sent by omission: the
sidecar treats any present value as truthy, so a literal "false" would
have arranged every slice.

Arrange unions with the #1493 cross-class decision rather than replacing
it, and the per-plate slice-all loop is now keyed on the arrange flag
itself — the project-wide collapse belongs to --arrange, not to the
cross-class case. The loop also covers the embedded-settings path, whose
crash-retry is suppressed there since a single --slice 0 retry would
return one consolidated plate.
2026-08-03 14:10:02 +02:00
maziggy ea63355fde Fix cross-model queue items being misrepresented and editable into a broken state (#671)
The edit dialog offered a printer picker and target-model dropdown for
an item with alternatives. Saving left a row with variants AND a
printer_id, and the scheduler's fixed-printer branch wins that race, so
it dispatched a row whose library_file_id is still null and failed in
the upload. PATCH now refuses printer/model changes on such an item —
comparing against the current value, since the dialog re-sends
target_model unchanged — and the route eager-loads variants, without
which the guard could not see them and every PATCH response dropped the
alternatives from its payload.

Names come from a shared helper now. A cross-model item holds neither
archive_id nor library_file_id until dispatch, so five separate inlined
fallbacks all rendered "File #null"; they now read "x1c.gcode.3mf +1
more".

The queue also grouped these under "Any H2D" — the first candidate
mirrored onto the row — filing a job under a printer it might never run
on. It groups as "Any H2D / X1C", matching the row beneath it.
2026-08-03 12:33:19 +02:00
maziggy ef7c1b21f1 Add cross-model print alternatives to the File Manager and print modal (#671, #2570)
Selecting several sliced files and pressing Print now creates one queue
item carrying all of them, instead of hiding the Print button the moment
a second file is selected. The printer picker is replaced by the ordered
candidate list, since choosing these files is already the answer to
"which printer" and the only question left is which is preferred.

Per-candidate configuration is the plate only. Model-based assignment
sends no AMS mapping — the printer is unknown until dispatch, where the
scheduler derives it — so a per-candidate mapping editor would collect
choices it then discards. Filament overrides stay shared: "this job
needs PETG" holds for every slice of the same job.

Adds Group as versions for durable grouping, a versions badge counting
the whole group rather than the rows on screen, and a queue card label
naming every model a pending item is waiting on.
2026-08-03 11:33:39 +02:00
maziggy b04664c64a Raise the chamber-temperature ceiling from 60 to 65 C
Every field that takes a chamber target stopped at 60: the per-filament
chamber map and per-print override in Preheat & Heat Soak, the chamber
quick-select presets, and the printer-card chamber control. 60 is the
X1E's ceiling and the X1E was the only heated-chamber model when that
limit was written; the H2 series and X2D heat to 65, so the top of their
range was unreachable.

The ceiling now lives in one constant per side (MAX_CHAMBER_TEMP_C in
backend/app/utils/printer_models.py and frontend/src/utils/printer.ts)
rather than as a literal at each call site. X1E firmware clamps a higher
request to its own maximum, so a shared ceiling is safe.

Also fixes a live bug at PrintersPage.tsx:7985: parsePresetTriple was
bounded to 60 there, and it rejects the whole triple on any out-of-range
entry, so a saved 65 preset would have silently reverted the printer
card to the defaults while Settings still showed 65.
2026-08-03 08:52:23 +02:00
MartinNYHC 8be3413fbd Merge branch 'main' into dev 2026-08-02 11:10:25 +02:00
maziggy 30daed2756 Fix per-job queue ETA showing for jobs that cannot start now
The scheduler only writes waiting_reason on the model-based assignment
path, so a job pinned to a specific printer sits behind a running print
with no marker at all. Every such job rendered an identical "starts now"
ETA that was wrong by the length of everything ahead of it.

Decide eligibility on the page instead: an item gets an ETA only when its
printer is idle and it is the item the scheduler would dispatch next,
following the same ordering the scheduler uses. Staged and future-
scheduled items do not block the item behind them, matching the
scheduler, and items conditional on a previous print are excluded.

The value also froze at first render, since react-query's structural
sharing keeps the queue reference stable and nothing re-rendered the row.
formatETA now accepts a base instant and the page drives it from a 30s
clock shared by every visible row.

Retire the borrowed printers.estimatedCompletion tooltip for a queue key
that says what the number means, translated into all 13 locales.
2026-08-02 08:28:26 +02:00
MartinNYHC 588665d5f4 Merge branch 'dev' into feature/queue-item-eta 2026-08-02 08:09:53 +02:00
mpl1337 0034fdc425 Fix queued ETA visibility and add rendering tests 2026-08-01 13:58:54 +02:00
MartinNYHC 8b46006644 Merge branch 'dev' into feature/oidc-env-config 2026-08-01 11:22:58 +02:00
MartinNYHC 1cdc0e289f Merge branch 'dev' into feature/queue-item-eta 2026-08-01 10:56:28 +02:00
maziggy 43cb216ae9 fix(settings): stop the Settings page reverting changes made elsewhere (issue #2716)
While the Settings page was mounted it held its own copy of every
setting and synced it from the server exactly once, on first load
(:887-900). A debounced effect then diffed the live ['settings']
cache against that copy and PUT all 77 keys it manages on any
difference, with no way to tell a user edit from a value that had
changed on the server. Anything written server-side while the page
sat open was silently reverted ~500ms later (#2716, reporter
@jmoore-skild).

No interaction was needed to trigger it. The query inherits a 60s
staleTime and react-query's default refetchOnWindowFocus, and ~30
other observers share the key, so a window refocus or a refetch from
any of them moved the cache and the page wrote its page-load snapshot
back over all 77 keys -- showing "Settings saved" while doing it.

The page now tracks the last server snapshot it reconciled with. A
field still equal to that baseline has not been touched since, so a
newer server value is adopted; a field the user has edited keeps
their value and is saved over the top, so the newer of the two writes
wins either way. Typing into a text field while a refetch lands stays
safe, which is what the previous behaviour was protecting -- an
in-progress edit is by definition different from the baseline.

The baseline is seeded from the raw server row rather than from the
copy the page patches a browser-detected external_url into, so that
detection still reads as a local change and is still persisted.

The payload builder and the comparison key lists are unchanged. The
diff simply measures against the baseline instead of the live cache,
so no field can silently stop saving.

Removing the adoption step was verified to reintroduce the revert,
and removing the post-save baseline advance to reintroduce a resend
loop; both are covered by frontend tests asserting on the request
bodies rather than on rendered values.
2026-08-01 10:55:08 +02:00
maziggy 18938a10ee fix(kprofiles): stop reporting rejected K-profile writes as saved
Saving a K-profile was fire-and-forget. set_kprofiles_batch published
and returned True, and the printer's extrusion_cali_set answer was
logged at DEBUG and dropped, so a write the printer refused was
reported to the user as saved (#2718, reporter @jmoore-skild).

The reason it could not simply be gated on: the answer itself was
wrong. Single-nozzle firmware returned result:"fail" with
reason:"invalid tray_id" on writes that demonstrably applied.
Measured against an X1C and an H2D over MQTT, the cause is the
tray_id:-1 Bambuddy itself put in the payload. Sending three
otherwise identical writes isolated it: tray_id:-1 fails, tray_id:0
succeeds, and cali_idx:-1 is accepted either way, so only that one
field is at fault. The H2D ignores the value entirely; the X1C
validates it, complains, and applies the write anyway. BambuStudio
always sends a real tray_id and defaults it to 0 for a manually
entered profile.

With tray_id:0 the acknowledgement is honest, and the printer echoes
back the sequence_id we sent -- confirmed for extrusion_cali_get,
_set and _del on both printer classes -- so it can be matched to the
write that caused it. Writes now return their sequence_id and the
routes await the verdict, turning a real failure into an error that
carries the printer's own reason. A printer that stays silent is
still treated as success: no answer is not evidence of refusal, and
firmware that never answers must not turn every save into an error.

Raises the ack to INFO. It sat at DEBUG, so the one line that
explains a failed save was absent from every support bundle -- the
same reasoning that put ams_filament_drying at INFO for #1447.

Also fixes extrusion_cali_set building its payload from
str(self._sequence_id) without incrementing first, reusing the
previous command's id. Harmless while nothing correlated on it,
fatal now that the write path does.

Adds supports_nozzle_flow_type() for the Standard / High Flow choice,
which the K-Profiles UI previously showed as "Not reported by
printer" -- not a value anyone can save. Most printers omit the
nozzle identity from their calibration table entirely, and the slicer
treats that as Standard rather than unknown; Bambuddy now does the
same and keeps the choice editable. The field is hidden only where
the model ships a single nozzle variant, using the slicer's own rule
(len(nozzle_volume) // len(nozzle_diameter) > 1 over the machine
preset) evaluated across every bundled Bambu profile. That puts only
A1, A1 Mini and A2L on the hidden side -- it is not the single-
versus-dual-nozzle split, since P1P, P1S, P2S, X1, X1C, X1E and H2S
are all single-nozzle and all carry two variants. Editing a profile
also no longer writes back an empty nozzle_id.

Wiki records that on printers which omit the field the chosen flow
type is discarded by the firmware and reads back as Standard, in
Bambu Studio as well, so it does not get filed as a bug again.
2026-08-01 10:35:22 +02:00