mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-21 18:47:13 +02:00
fix(backup): report the categories a failed restore already committed (#2656)
The service reports what landed on a part-way failure -- categories commit as they finish, so results names the ones on disk -- and the modal gated the whole result panel on success, so it showed the failure message and dropped them. The cache invalidation was inside that same branch, which is the half that mattered: a run that committed the settings category and then failed left the app rendering pre-restore settings, with no reload and no re-read, which is the failure the modal's own reload-on-close exists to prevent. Gate on what was written instead. A refusal that never reached a category still carries an empty results and still keeps the form, so the mutex and backup-in-flight cases are unchanged. A partial does not read as a success: the tick becomes a warning and a line says the listed categories are the ones on disk. --- fix(backup): keep the local owner when the backup names one we cannot resolve (#2656) An owner the backup names but this instance has no user for was written as NULL, and overwrite is a blanket setattr -- so restoring over a local archive that had a perfectly good owner took it away, which is the 404-for-its-own- owner failure this column is carried across to fix. Resolving by username widened the trigger from a stale id to any user renamed since the backup. It is the same state as an absent key: the backup has not told us who owns this. So it takes the same action -- the column is not written at all. Overwrite keeps the local owner, insert lands ownerless with the note, and an explicit null still writes, so overwrite still means "match the backup". The notes move to the insert path with it. On overwrite nothing was taken away, so there is nothing to warn about, which is the rule the absent-key case already follows.
This commit is contained in:
+1
-1
@@ -5,7 +5,7 @@ All notable changes to Bambuddy will be documented in this file.
|
||||
## [1.2.6b1] - Unreleased
|
||||
|
||||
### Added
|
||||
- **Restore selected categories from a Git backup commit (#2656)** — Bambuddy has pushed backups to GitHub, GitLab, Gitea and Forgejo for a long time, and every one of those commits was a restore point that nothing could read back. Recovering from a bad settings change, a rebuilt instance or a lost database meant opening the repository by hand and copying JSON into the right places, if you knew which places those were. **Settings → Backup & Restore → Restore from Git** now picks any of the twenty most recent commits and pulls back the categories you tick — K-profiles, app settings, spool inventory and print history — without touching anything you did not select. The modal previews the commit before anything is written: it shows how many items each category holds and greys out the ones that commit does not contain, so a category you only enabled last week is visibly absent from older commits rather than silently restoring nothing. **Overwrite existing entries** decides what happens when something already exists locally — off, it fills in what is missing and leaves the rest alone; on, it makes the local row match the backup. The result panel reports what actually happened per category as restored, skipped and failed, and those three always add up to the number the preview showed you, so a count that does not match the preview is a bug rather than something to interpret. Restoring never resurrects a credential: the backup carries MQTT, LDAP, Home Assistant and Prometheus secrets so that a repository is a complete record, but the restore refuses every one of them, and refuses along with them any switch that would be left pointing at a service it can no longer authenticate to — an exposed Prometheus endpoint with no token is worse than one that stays off. The keys that decide who can reach the instance at all are refused outright for the same reason — the four authentication-policy switches, and the whole LDAP family alongside them, since those name *which directory server decides who you are* rather than how the instance behaves. Authentication is reconfigured through the auth UI, which has the guards that a JSON file does not. Print archives come back as history only, since a Git backup holds metadata and never the 3MF or thumbnail bytes, and each one is returned to its owner by username rather than by user id — an id means nothing on a rebuilt instance, where it would hand one person's print history to whoever now holds that number. An archive whose owner this instance does not have lands unowned with a note saying so, rather than being attributed to a stranger. K-profiles are the one category that leaves the database: they are sent to the printer over MQTT, which means the printer must be online, and writing a slot is always an overwrite there regardless of the toggle — the modal says so before you click rather than in the summary afterwards. Cloud profiles are backed up but deliberately not restorable, as writing them means writing to a Bambu or Orca account rather than to this instance. **Restoring is permissioned per category**: `github:restore` opens the dialog, and each category additionally requires the permission that owns its rows — `settings:update`, `inventory:update`, `archives:update_all` and `kprofiles:update` — so a role cannot write through a restore what it cannot write through the page that owns it. Administrators hold all of them already; a custom role built around the Backup permissions alone can open the dialog and preview a commit, but needs the owning permission for each category you want it to be able to write. Each category is committed as it completes rather than at the end, so a large restore does not hold the database against the rest of Bambuddy for the length of the run; the trade is that a failure part-way through leaves the categories that already finished in place, which the result panel reports rather than claiming nothing was restored. Translated in all locales; wiki updated. Covered by backend and frontend tests.
|
||||
- **Restore selected categories from a Git backup commit (#2656)** — Bambuddy has pushed backups to GitHub, GitLab, Gitea and Forgejo for a long time, and every one of those commits was a restore point that nothing could read back. Recovering from a bad settings change, a rebuilt instance or a lost database meant opening the repository by hand and copying JSON into the right places, if you knew which places those were. **Settings → Backup & Restore → Restore from Git** now picks any of the twenty most recent commits and pulls back the categories you tick — K-profiles, app settings, spool inventory and print history — without touching anything you did not select. The modal previews the commit before anything is written: it shows how many items each category holds and greys out the ones that commit does not contain, so a category you only enabled last week is visibly absent from older commits rather than silently restoring nothing. **Overwrite existing entries** decides what happens when something already exists locally — off, it fills in what is missing and leaves the rest alone; on, it makes the local row match the backup. The result panel reports what actually happened per category as restored, skipped and failed, and those three always add up to the number the preview showed you, so a count that does not match the preview is a bug rather than something to interpret. Restoring never resurrects a credential: the backup carries MQTT, LDAP, Home Assistant and Prometheus secrets so that a repository is a complete record, but the restore refuses every one of them, and refuses along with them any switch that would be left pointing at a service it can no longer authenticate to — an exposed Prometheus endpoint with no token is worse than one that stays off. The keys that decide who can reach the instance at all are refused outright for the same reason — the four authentication-policy switches, and the whole LDAP family alongside them, since those name *which directory server decides who you are* rather than how the instance behaves. Authentication is reconfigured through the auth UI, which has the guards that a JSON file does not. Print archives come back as history only, since a Git backup holds metadata and never the 3MF or thumbnail bytes, and each one is returned to its owner by username rather than by user id — an id means nothing on a rebuilt instance, where it would hand one person's print history to whoever now holds that number. An archive whose owner this instance does not have lands unowned with a note saying so, rather than being attributed to a stranger; one that already exists locally keeps the owner it already has, because an owner the backup cannot name is not an instruction to take one away. K-profiles are the one category that leaves the database: they are sent to the printer over MQTT, which means the printer must be online, and writing a slot is always an overwrite there regardless of the toggle — the modal says so before you click rather than in the summary afterwards. Cloud profiles are backed up but deliberately not restorable, as writing them means writing to a Bambu or Orca account rather than to this instance. **Restoring is permissioned per category**: `github:restore` opens the dialog, and each category additionally requires the permission that owns its rows — `settings:update`, `inventory:update`, `archives:update_all` and `kprofiles:update` — so a role cannot write through a restore what it cannot write through the page that owns it. Administrators hold all of them already; a custom role built around the Backup permissions alone can open the dialog and preview a commit, but needs the owning permission for each category you want it to be able to write. Each category is committed as it completes rather than at the end, so a large restore does not hold the database against the rest of Bambuddy for the length of the run; the trade is that a failure part-way through leaves the categories that already finished in place, which the result panel reports rather than claiming nothing was restored. Translated in all locales; wiki updated. Covered by backend and frontend tests.
|
||||
- **Home Assistant sensors on the printer card, with an optional print interlock (#1148, reporter @bsaunder; #448, reporter @baudneo)** — Bambuddy could already switch a Home Assistant entity as a smart plug, but it had no way to *read* one. A printer in a home-built enclosure with a door contact, or an A1 with an aftermarket chamber thermometer, had all that data in Home Assistant and none of it in Bambuddy — the reporter's actual problem being that he could not tell whether he had left the enclosure open before starting a print from his phone. **Settings → Smart Plugs → Home Assistant Sensors** now binds any `binary_sensor` — a door, window, smoke or moisture contact — or any `sensor` that carries a reading to a printer, and its state appears on that printer's card. The wording follows Home Assistant's own device class, so a door reads Open or Closed rather than On or Off, and a thermometer reads `41.2 °C`; entities with no device class fall back to on/off, exactly as Home Assistant shows them. A sensor can be given an alert condition — on, off, above a value, below a value — which highlights it on the card and unlocks two things it would otherwise be pointless to offer. **Notify on alert** sends a notification the moment the sensor enters that state, once on the way in rather than on every poll, and not again when a flaky contact drops off the network and comes back still alerting. **Hold prints while alerting** is the part that answers the original question without anyone having to look: queued jobs for that printer wait, with a reason on the Queue page you can read at a glance ("Waiting on Enclosure Door"), and start by themselves as soon as the door shuts. Nothing is ever cancelled, and a job queued as "Any X1C" simply goes to a sibling whose sensors are clear instead of waiting behind the one that is held. The interlock is deliberately one-directional: it holds only on a sensor that was read successfully and *is* alerting, so a Home Assistant that is unreachable holds nothing and the queue keeps running as though no interlock were configured. Sensors are their own thing rather than a smart plug with a wider entity filter — a plug carries auto-on, schedules, power alerts and "controls printer power", and the printer card's power button would have happily tried to switch a door contact. One backend poller reads every bound entity every 15 seconds and the cards serve that cached reading, so the cost to Home Assistant does not grow with the number of printers on screen or browser tabs open. Off by default in every respect: a newly bound sensor is display-only until you give it an alert condition, and both the notification and the interlock are separate opt-ins on top of that.
|
||||
- **Open a File Manager model in your desktop slicer, and pick which one from the 3D preview (#2725, contributor @pascalheidmann)** — The **Slice** action on a file card only existed when the optional slicer sidecar was running. Turn the sidecar off — which is the default, and how most installs run — and the File Manager offered no way to get a model into a slicer at all, even though the Archives page has handed files to a locally-installed Bambu Studio or OrcaSlicer over the URI scheme for a long time. The File Manager was simply the one place that never got it. **Slice** now appears on every unsliced model (`.3mf`, `.stl`, `.step`, `.stp`) in both the card menu and the list view, and does whichever of the two things your configuration means: with the sidecar on it opens Bambuddy's slice modal and the work happens on the server, and with it off it hands the file to your desktop slicer. The icon says which you will get before you click — a cog for server-side slicing, an external-link arrow for the handoff — and which slicer receives the handoff comes from **Settings → Workflow → Slicer → Open in Slicer**, falling back to the preferred slicer as it always has. The 3D preview goes further, because that is where you are actually looking at the model and deciding: its slicer button is now a split button, and the chevron beside it offers the alternatives without changing any setting. With the sidecar off that is the slicer you did *not* pick as your desktop target; with it on, the primary button still slices server-side and the menu offers a one-off desktop handoff to either slicer. Two things that used to be silent now are not: a handoff refused for want of permission raises an error toast rather than launching the slicer at a URL it cannot fetch, where a permission problem looked exactly like "no slicer installed"; and the file-type rule is shared between the card menu and the 3D preview, so a file can no longer offer **Slice** in one place while showing it greyed out in the other. Permissions follow the endpoint each mode calls — the handoff is a download and needs the same library read permission that lets you see the file, server-side slicing writes a new file and needs upload rights — and the action is shown disabled with the missing permission named rather than hidden. Translated in all locales; wiki updated. Covered by frontend tests.
|
||||
- **Temperatures on the streaming overlay, and a builder for its URL (#1422, reporter @SMAW)** — 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 could already be tuned — 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. Both are now addressed. Nozzle, bed and chamber readings join the list, shown with the target while the heater is still climbing and with the target dropped once it is reached, so a settled hotend reads "220°C" rather than "220 / 220°C" for the rest of the print. Both nozzles appear on a dual-nozzle printer. They are drawn whether or not a print is running, since a preheating machine is exactly when they are worth watching, and each reading appears only when the printer genuinely reports it — chamber temperature stays absent on P1 and A1 models, which publish a value with no sensor behind it. And **Settings → API Keys → Streaming Overlay** now builds the URL for you: pick the printer, tick the fields, set size and frame rate, paste in a token if login is enabled, and copy the result, with an optional preview alongside it. The preview stays off until you ask for it so that leaving the settings page open does not hold a viewer on the printer's single camera connection. Making that preview possible needed one narrow change to the security headers: the overlay path now sends `frame-ancestors 'self'` instead of `'none'`, so Bambuddy's own UI can embed it. Every other page still refuses to be framed at all, `'self'` permits a framer only on this same origin, and embedding the overlay from another host — Home Assistant on a different port, say — is unchanged and still requires `TRUSTED_FRAME_ORIGINS`. Temperatures are not in the default field set, so an overlay URL already pasted into a scene looks exactly the same after upgrading. Translated in all locales, wiki updated, covered by backend and frontend tests.
|
||||
|
||||
@@ -1105,6 +1105,16 @@ class GitHubRestoreService:
|
||||
# column was added to fix) and silently un-delete a row the user
|
||||
# deleted. So only carry a column the backup actually knows about;
|
||||
# on insert, an absent key just takes the model default.
|
||||
# An owner the backup names but this instance cannot resolve is the
|
||||
# same epistemic state as an absent key — we do not know who owns
|
||||
# this archive — so it takes the same action: the column is left out
|
||||
# of ``fields`` entirely rather than set to None. Writing NULL there
|
||||
# would take the owner away from a local archive that has a perfectly
|
||||
# good one, which is the 404-for-its-own-owner failure this column is
|
||||
# carried across to fix, and it would do it on the overwrite path
|
||||
# where there is a local answer to keep. On insert there is nothing
|
||||
# to keep, so the row takes the model default and lands ownerless,
|
||||
# which is what the note says.
|
||||
owner_cleared = False
|
||||
backup_username = entry.get("created_by_username")
|
||||
if isinstance(backup_username, str) and backup_username:
|
||||
@@ -1112,35 +1122,42 @@ class GitHubRestoreService:
|
||||
# since the backup, and there is nothing else to resolve on: the
|
||||
# id alongside it is from the source instance's numbering, so
|
||||
# trusting it is exactly the misattribution the name is here to
|
||||
# prevent. Cleared rather than failing the row — the archive is
|
||||
# still worth having, and an admin can reassign it — but said out
|
||||
# loud, because a cleared owner is not silent-safe.
|
||||
# prevent. Not a reason to fail the row — the archive is still
|
||||
# worth having, and an admin can reassign it — but said out loud
|
||||
# on insert, because an ownerless archive is not silent-safe.
|
||||
created_by_id = users_by_name.get(backup_username)
|
||||
if created_by_id is None:
|
||||
tally.note(
|
||||
"archivesOwnerUnmatched",
|
||||
"Some archives name an owner this instance does not have — owner cleared rather than "
|
||||
"guessed from the backup's user id, so they are visible only to users with the "
|
||||
"archives:read_all permission until an admin reassigns them",
|
||||
)
|
||||
owner_cleared = True
|
||||
fields["created_by_id"] = created_by_id
|
||||
if existing is None:
|
||||
tally.note(
|
||||
"archivesOwnerUnmatched",
|
||||
"Some archives name an owner this instance does not have — owner cleared rather than "
|
||||
"guessed from the backup's user id, so they are visible only to users with the "
|
||||
"archives:read_all permission until an admin reassigns them",
|
||||
)
|
||||
owner_cleared = True
|
||||
else:
|
||||
fields["created_by_id"] = created_by_id
|
||||
elif "created_by_id" in entry:
|
||||
# Fallback for a commit taken before the collector recorded the
|
||||
# username. Validated rather than trusted, so a *stale* id clears
|
||||
# instead of pointing somewhere wrong; a live id belonging to a
|
||||
# different person on a rebuilt instance is the case this path
|
||||
# cannot see, and is why the branch above exists.
|
||||
# username. Validated rather than trusted, so a *stale* id is
|
||||
# dropped instead of pointing somewhere wrong; a live id
|
||||
# belonging to a different person on a rebuilt instance is the
|
||||
# case this path cannot see, and is why the branch above exists.
|
||||
# An explicit null is not a miss — the backup is saying the
|
||||
# archive had no owner — so it is written, and overwrite keeps
|
||||
# meaning "make the local row match the backup".
|
||||
created_by_id = entry.get("created_by_id")
|
||||
if created_by_id is not None and created_by_id not in valid_users:
|
||||
tally.note(
|
||||
"archivesOwnerCleared",
|
||||
"Some archives referenced users that no longer exist — owner cleared, so they are "
|
||||
"visible only to users with the archives:read_all permission until an admin reassigns them",
|
||||
)
|
||||
created_by_id = None
|
||||
owner_cleared = True
|
||||
fields["created_by_id"] = created_by_id
|
||||
if existing is None:
|
||||
tally.note(
|
||||
"archivesOwnerCleared",
|
||||
"Some archives referenced users that no longer exist — owner cleared, so they are "
|
||||
"visible only to users with the archives:read_all permission until an admin "
|
||||
"reassigns them",
|
||||
)
|
||||
owner_cleared = True
|
||||
else:
|
||||
fields["created_by_id"] = created_by_id
|
||||
if "deleted_at" in entry:
|
||||
# A soft-deleted archive is still in the backup (its row is kept
|
||||
# so stats keep counting it), so carry the flag across or the
|
||||
@@ -1175,16 +1192,17 @@ class GitHubRestoreService:
|
||||
)
|
||||
warned_files = True
|
||||
|
||||
# Insert-only, and the mirror of the "absent is not null" rule above:
|
||||
# on overwrite an unknown owner correctly leaves the local one alone,
|
||||
# but there is no local row here to fall back on, so the archive
|
||||
# lands ownerless — a 404 for everyone without archives:read_all.
|
||||
# Two ways to get here: a commit taken before the collector recorded
|
||||
# the column (every pre-#2656 backup), or an archive that genuinely
|
||||
# had no owner on the source instance. Both restore fine and both
|
||||
# Insert-only, and the mirror of the rule above: an owner the backup
|
||||
# cannot tell us is never written, so on overwrite the local one
|
||||
# survives — but there is no local row here to fall back on, so the
|
||||
# archive lands ownerless, a 404 for everyone without
|
||||
# archives:read_all. Three ways to get here: a commit taken before
|
||||
# the collector recorded the column (every pre-#2656 backup), an
|
||||
# archive that genuinely had no owner on the source instance, or one
|
||||
# whose owner this instance cannot resolve. All restore fine and all
|
||||
# were silent, so the tally said "N archives restored" while the user
|
||||
# who asked for them saw none. The stale-id case above already said
|
||||
# its piece; don't say it twice for the same row.
|
||||
# who asked for them saw none. The unresolved cases above already
|
||||
# said their piece; don't say it twice for the same row.
|
||||
if fields.get("created_by_id") is None and not owner_cleared:
|
||||
tally.note(
|
||||
"archivesOwnerUnknown",
|
||||
@@ -1729,6 +1747,14 @@ class GitHubRestoreService:
|
||||
# counts it outstanding — leaving the tally here was the
|
||||
# one place a profile could vanish from
|
||||
# restored + skipped + failed entirely.
|
||||
#
|
||||
# failed here against skipped there is not a
|
||||
# disagreement about the entry. The three counters say
|
||||
# what happened to an item on this run, not whether it
|
||||
# was ever usable: an offline printer skips everything it
|
||||
# holds, well-formed or not, because nothing was
|
||||
# attempted, while here the entry was reached and could
|
||||
# not be used.
|
||||
tally.failed += 1
|
||||
continue
|
||||
match = self._match_kprofile(p, current, claimed)
|
||||
|
||||
@@ -2407,6 +2407,96 @@ class TestRestoredArchiveOwnership:
|
||||
row = (await db_session.execute(select(PrintArchive))).scalar_one()
|
||||
assert row.created_by_id == alice.id
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_overwrite_leaves_the_owner_alone_when_the_backup_names_someone_unknown(self, db_session):
|
||||
"""A name this instance cannot resolve is not an instruction to clear.
|
||||
|
||||
Same epistemic state as the absent key below -- the backup has not told
|
||||
us who owns this archive -- so it takes the same action. Writing NULL
|
||||
instead inflicted the 404-for-its-own-owner failure on a local row that
|
||||
was fine, and on a rebuilt instance every user renamed since the backup
|
||||
took a whole archive history with them.
|
||||
"""
|
||||
bob = await self._user(db_session, "bob")
|
||||
db_session.add(
|
||||
PrintArchive(
|
||||
filename="benchy.3mf",
|
||||
file_path="/data/benchy.3mf",
|
||||
file_size=2048,
|
||||
content_hash="abc123",
|
||||
started_at=datetime(2026, 3, 1, 10, 0, 0),
|
||||
created_by_id=bob.id,
|
||||
)
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
tally = _CategoryTally()
|
||||
await _service()._restore_archives(
|
||||
db_session,
|
||||
{"archives": [self._entry(created_by_id=4242, created_by_username="carol")]},
|
||||
True,
|
||||
tally,
|
||||
{},
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
row = (await db_session.execute(select(PrintArchive))).scalar_one()
|
||||
assert row.created_by_id == bob.id, "an owner we cannot resolve must not displace one we can"
|
||||
assert tally.restored == 1
|
||||
# Nothing was taken away, so there is nothing to warn about -- the same
|
||||
# rule the absent-key case follows.
|
||||
assert not any("owner cleared" in note for note in _messages(tally))
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_overwrite_leaves_the_owner_alone_when_the_backups_id_is_stale(self, db_session):
|
||||
"""The pre-username fallback takes the rule too."""
|
||||
bob = await self._user(db_session, "bob")
|
||||
db_session.add(
|
||||
PrintArchive(
|
||||
filename="benchy.3mf",
|
||||
file_path="/data/benchy.3mf",
|
||||
file_size=2048,
|
||||
content_hash="abc123",
|
||||
started_at=datetime(2026, 3, 1, 10, 0, 0),
|
||||
created_by_id=bob.id,
|
||||
)
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
tally = _CategoryTally()
|
||||
await _service()._restore_archives(db_session, {"archives": [self._entry(created_by_id=4242)]}, True, tally, {})
|
||||
await db_session.commit()
|
||||
|
||||
row = (await db_session.execute(select(PrintArchive))).scalar_one()
|
||||
assert row.created_by_id == bob.id
|
||||
assert not any("owner cleared" in note for note in _messages(tally))
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_an_unresolvable_name_still_lands_ownerless_on_insert(self, db_session):
|
||||
"""Control for the two above: with no local row there is nothing to keep.
|
||||
|
||||
The archive is still restored -- it is worth having -- but it is
|
||||
invisible to everyone without archives:read_all, so it is said out loud.
|
||||
"""
|
||||
await self._user(db_session, "alice")
|
||||
tally = _CategoryTally()
|
||||
|
||||
await _service()._restore_archives(
|
||||
db_session,
|
||||
{"archives": [self._entry(created_by_id=4242, created_by_username="carol")]},
|
||||
False,
|
||||
tally,
|
||||
{},
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
row = (await db_session.execute(select(PrintArchive))).scalar_one()
|
||||
assert row.created_by_id is None
|
||||
assert tally.restored == 1 and tally.failed == 0
|
||||
assert any("does not have" in note and "archives:read_all" in note for note in _messages(tally))
|
||||
# One cause, one note -- the ownerless-insert note must not pile on.
|
||||
assert not any("does not record one" in note for note in _messages(tally))
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_overwrite_leaves_the_owner_alone_when_the_backup_predates_the_key(self, db_session):
|
||||
"""A pre-#2656 commit must not blank the owner of a row that was fine.
|
||||
|
||||
@@ -432,11 +432,14 @@ describe('GitHubRestoreModal', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// A refused restore answers 200 with `success: false`, and two of the five
|
||||
// refusals are ordinary conditions rather than errors — a restore already
|
||||
// running, and a backup mid-flight. Rendering the result panel for those put a
|
||||
// green tick and "reload so the restored data appears" above a message saying
|
||||
// nothing had been restored, i.e. a failure that read as a success.
|
||||
// A refused restore answers 200 with `success: false` and an empty `results`,
|
||||
// and two of the five refusals are ordinary conditions rather than errors — a
|
||||
// restore already running, and a backup mid-flight. Rendering the result panel
|
||||
// for those put a green tick and "reload so the restored data appears" above a
|
||||
// message saying nothing had been restored, i.e. a failure that read as a
|
||||
// success. Empty `results` is the load-bearing half: a failure that did write
|
||||
// carries its committed categories and does get the panel — see the partial
|
||||
// test below.
|
||||
it('reports a backend refusal such as the backup/restore mutex', async () => {
|
||||
server.use(
|
||||
http.post('/api/v1/github-backup/restore', () =>
|
||||
@@ -488,7 +491,9 @@ describe('GitHubRestoreModal', () => {
|
||||
await waitFor(() => screen.getByText('A restore is already running'));
|
||||
|
||||
const keys = invalidate.mock.calls.map((c) => JSON.stringify(c[0]?.queryKey));
|
||||
// Nothing was written, so nothing to re-read...
|
||||
// This refusal never reached a category, so `results` is empty and there is
|
||||
// nothing to re-read. A failure that committed one does invalidate — the
|
||||
// partial test below covers that side...
|
||||
expect(keys).not.toContain(JSON.stringify(['spools']));
|
||||
expect(keys).not.toContain(JSON.stringify(['archives']));
|
||||
// ...but a failure past the commit resolve writes a "failed" log row, so the
|
||||
@@ -497,6 +502,62 @@ describe('GitHubRestoreModal', () => {
|
||||
invalidate.mockRestore();
|
||||
});
|
||||
|
||||
// Categories commit as each one finishes, so a run that fails part-way leaves
|
||||
// the earlier ones on disk and reports them. The modal used to gate the whole
|
||||
// result panel — and the cache invalidation with it — on `success`, so those
|
||||
// rows were written, never shown, and never re-read: the app carried on
|
||||
// displaying pre-restore settings while the database held the restored ones.
|
||||
const partialRestore = {
|
||||
success: false,
|
||||
message: 'database is locked',
|
||||
log_id: 7,
|
||||
ref: 'a'.repeat(40),
|
||||
results: {
|
||||
archives: { restored: 12, skipped: 0, failed: 0, notes: [] },
|
||||
settings: { restored: 4, skipped: 1, failed: 0, notes: [] },
|
||||
},
|
||||
};
|
||||
|
||||
const runRestore = async () => {
|
||||
const checkboxes = await waitFor(() => screen.getAllByRole('checkbox') as HTMLInputElement[]);
|
||||
await userEvent.click(checkboxes[1]);
|
||||
await userEvent.click(screen.getByRole('button', { name: /Restore$/ }));
|
||||
await waitFor(() => screen.getByText('Restore from backup?'));
|
||||
const confirmButtons = screen.getAllByRole('button', { name: /Restore$/ });
|
||||
await userEvent.click(confirmButtons[confirmButtons.length - 1]);
|
||||
};
|
||||
|
||||
it('reports the categories a part-way failure already committed', async () => {
|
||||
server.use(http.post('/api/v1/github-backup/restore', () => HttpResponse.json(partialRestore)));
|
||||
render(<GitHubRestoreModal onClose={vi.fn()} />);
|
||||
|
||||
await runRestore();
|
||||
|
||||
// The tallies are the point: they name what is on disk.
|
||||
await waitFor(() => expect(screen.getByText('database is locked')).toBeInTheDocument());
|
||||
expect(screen.getByText(/12 restored/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/4 restored/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/The categories listed above finished and are on disk/)).toBeInTheDocument();
|
||||
// And it must not read as a success — the run did not finish, so the
|
||||
// warning icon stands in for the green tick.
|
||||
expect(document.querySelector('svg.text-yellow-500')).toBeInTheDocument();
|
||||
expect(document.querySelector('svg.text-bambu-green')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('refreshes the data caches for a part-way failure, because rows landed', async () => {
|
||||
server.use(http.post('/api/v1/github-backup/restore', () => HttpResponse.json(partialRestore)));
|
||||
const invalidate = vi.spyOn(QueryClient.prototype, 'invalidateQueries');
|
||||
render(<GitHubRestoreModal onClose={vi.fn()} />);
|
||||
|
||||
await runRestore();
|
||||
await waitFor(() => screen.getByText('database is locked'));
|
||||
|
||||
const keys = invalidate.mock.calls.map((c) => JSON.stringify(c[0]?.queryKey));
|
||||
expect(keys).toContain(JSON.stringify(['archives']));
|
||||
expect(keys).toContain(JSON.stringify(['settings']));
|
||||
invalidate.mockRestore();
|
||||
});
|
||||
|
||||
// A provider-side failure answers 200 with `success: false`; a rejected
|
||||
// *request* throws in `request()`, leaving `data` undefined. Reading the
|
||||
// message off `data` alone meant the second kind rendered an empty modal —
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
AlertTriangle,
|
||||
Archive,
|
||||
CheckCircle2,
|
||||
Info,
|
||||
@@ -147,13 +148,20 @@ export function GitHubRestoreModal({ onClose }: GitHubRestoreModalProps) {
|
||||
onSuccess: (data) => {
|
||||
setShowConfirm(false);
|
||||
// The endpoint answers 200 for a refused or failed restore too, with
|
||||
// `success: false` and an empty `results` — and two of those are ordinary
|
||||
// conditions, not errors: another restore already running, and a backup
|
||||
// being mid-flight. Rendering the result panel for them showed a green
|
||||
// tick, no tally at all and a "reload so the restored data appears" hint
|
||||
// above a message saying nothing had been restored. Only a real success
|
||||
// gets the panel; a failure keeps the form and shows the red block below.
|
||||
if (data.success) {
|
||||
// `success: false` — and two of those are ordinary conditions, not
|
||||
// errors: another restore already running, and a backup being mid-flight.
|
||||
// Nothing was written for either, so they keep the form and show the red
|
||||
// block below; rendering the result panel for them put a green tick, no
|
||||
// tally at all and a "reload so the restored data appears" hint above a
|
||||
// message saying nothing had been restored.
|
||||
//
|
||||
// A failure that got as far as writing is the opposite case. Categories
|
||||
// commit as each one finishes, so a non-empty `results` names the ones
|
||||
// that are on disk — and the form over the top of them would be the same
|
||||
// "nothing was restored" misreading, this time with the data actually in.
|
||||
// So the panel is what wrote, not what succeeded.
|
||||
const wroteSomething = Object.keys(data.results ?? {}).length > 0;
|
||||
if (data.success || wroteSomething) {
|
||||
setResult(data);
|
||||
// A restore rewrites rows these caches hold. ['settings'] is one of
|
||||
// them: until #2716 was fixed on dev, invalidating it made
|
||||
@@ -280,10 +288,22 @@ export function GitHubRestoreModal({ onClose }: GitHubRestoreModalProps) {
|
||||
{result ? (
|
||||
/* Result summary */
|
||||
<div className="p-4 space-y-3 max-h-[400px] overflow-y-auto">
|
||||
{/* A partial restore reaches this panel too — categories commit
|
||||
as they finish, so the tallies below are on disk even though
|
||||
the run did not get through them all. It must not read as a
|
||||
success: the message is the failure, and what follows is what
|
||||
survived it rather than what was asked for. */}
|
||||
<div className="flex items-start gap-2 text-sm">
|
||||
<CheckCircle2 className="w-4 h-4 text-bambu-green mt-0.5 flex-shrink-0" />
|
||||
{result.success ? (
|
||||
<CheckCircle2 className="w-4 h-4 text-bambu-green mt-0.5 flex-shrink-0" />
|
||||
) : (
|
||||
<AlertTriangle className="w-4 h-4 text-yellow-500 mt-0.5 flex-shrink-0" />
|
||||
)}
|
||||
<span className="text-white">{result.message}</span>
|
||||
</div>
|
||||
{!result.success && (
|
||||
<p className="text-xs text-bambu-gray">{t('backup.restoreFromGit.partialHint')}</p>
|
||||
)}
|
||||
{Object.entries(result.results).map(([name, tally]) => (
|
||||
<div key={name} className="p-3 rounded-lg bg-bambu-dark border border-bambu-dark-tertiary">
|
||||
<div className="flex items-center justify-between">
|
||||
|
||||
@@ -4895,6 +4895,7 @@ export default {
|
||||
kprofilesOverwriteCaveat: 'K-Profile sind die Ausnahme: Das Schreiben eines Slots ersetzt immer die Kalibrierung auf dem Drucker.',
|
||||
tally: '{{restored}} wiederhergestellt, {{skipped}} übersprungen, {{failed}} fehlgeschlagen',
|
||||
reloadHint: 'Bambuddy neu laden, damit die wiederhergestellten Daten überall erscheinen.',
|
||||
partialHint: 'Die oben aufgeführten Kategorien wurden abgeschlossen und sind gespeichert. Fehlende Kategorien wurden nicht ausgeführt.',
|
||||
failed: 'Wiederherstellung fehlgeschlagen.',
|
||||
loadFailed: 'Das Backup-Repository konnte nicht gelesen werden.',
|
||||
details: {
|
||||
|
||||
@@ -4938,6 +4938,7 @@ export default {
|
||||
kprofilesOverwriteCaveat: 'K-profiles are the exception: writing a slot always replaces the calibration on the printer.',
|
||||
tally: '{{restored}} restored, {{skipped}} skipped, {{failed}} failed',
|
||||
reloadHint: 'Reload Bambuddy so the restored data appears everywhere.',
|
||||
partialHint: 'The categories listed above finished and are on disk. Any that are missing did not run.',
|
||||
failed: 'Restore failed.',
|
||||
loadFailed: 'Could not read the backup repository.',
|
||||
// Preview caveats. The server sends detail_code + detail_params and the
|
||||
|
||||
@@ -4903,6 +4903,7 @@ export default {
|
||||
kprofilesOverwriteCaveat: 'Los perfiles K son la excepción: escribir una ranura siempre reemplaza la calibración en la impresora.',
|
||||
tally: '{{restored}} restaurados, {{skipped}} omitidos, {{failed}} fallidos',
|
||||
reloadHint: 'Recarga Bambuddy para que los datos restaurados aparezcan en todas partes.',
|
||||
partialHint: 'Las categorías indicadas arriba se completaron y están guardadas. Las que faltan no llegaron a ejecutarse.',
|
||||
failed: 'La restauración ha fallado.',
|
||||
loadFailed: 'No se pudo leer el repositorio de copias de seguridad.',
|
||||
details: {
|
||||
|
||||
@@ -4884,6 +4884,7 @@ export default {
|
||||
kprofilesOverwriteCaveat: "Les profils K sont l'exception : écrire un emplacement remplace toujours la calibration sur l'imprimante.",
|
||||
tally: '{{restored}} restaurés, {{skipped}} ignorés, {{failed}} en échec',
|
||||
reloadHint: 'Rechargez Bambuddy pour que les données restaurées apparaissent partout.',
|
||||
partialHint: "Les catégories listées ci-dessus sont terminées et enregistrées. Celles qui manquent n'ont pas été exécutées.",
|
||||
failed: 'Échec de la restauration.',
|
||||
loadFailed: 'Impossible de lire le dépôt de sauvegarde.',
|
||||
details: {
|
||||
|
||||
@@ -4883,6 +4883,7 @@ export default {
|
||||
kprofilesOverwriteCaveat: "I profili K sono l'eccezione: scrivere uno slot sostituisce sempre la calibrazione sulla stampante.",
|
||||
tally: '{{restored}} ripristinati, {{skipped}} saltati, {{failed}} non riusciti',
|
||||
reloadHint: 'Ricarica Bambuddy per vedere i dati ripristinati in tutte le sezioni.',
|
||||
partialHint: 'Le categorie elencate sopra sono state completate e salvate. Quelle mancanti non sono state eseguite.',
|
||||
failed: 'Ripristino non riuscito.',
|
||||
loadFailed: 'Impossibile leggere il repository di backup.',
|
||||
details: {
|
||||
|
||||
@@ -4895,6 +4895,7 @@ export default {
|
||||
kprofilesOverwriteCaveat: 'Kプロファイルは例外です。スロットへの書き込みは、プリンター上のキャリブレーションを常に置き換えます。',
|
||||
tally: '復元 {{restored}} 件、スキップ {{skipped}} 件、失敗 {{failed}} 件',
|
||||
reloadHint: '復元したデータを全体に反映するには Bambuddy を再読み込みしてください。',
|
||||
partialHint: '上に表示されたカテゴリーは完了し、保存されています。表示されていないカテゴリーは実行されていません。',
|
||||
failed: '復元に失敗しました。',
|
||||
loadFailed: 'バックアップリポジトリを読み取れませんでした。',
|
||||
details: {
|
||||
|
||||
@@ -4660,6 +4660,7 @@ export default {
|
||||
kprofilesOverwriteCaveat: 'K 프로파일은 예외입니다. 슬롯에 쓰면 프린터의 캘리브레이션이 항상 교체됩니다.',
|
||||
tally: '복원 {{restored}}개, 건너뜀 {{skipped}}개, 실패 {{failed}}개',
|
||||
reloadHint: '복원된 데이터가 모든 화면에 반영되도록 Bambuddy를 새로 고치세요.',
|
||||
partialHint: '위에 표시된 카테고리는 완료되어 저장되었습니다. 표시되지 않은 카테고리는 실행되지 않았습니다.',
|
||||
failed: '복원에 실패했습니다.',
|
||||
loadFailed: '백업 저장소를 읽을 수 없습니다.',
|
||||
details: {
|
||||
|
||||
@@ -4883,6 +4883,7 @@ export default {
|
||||
kprofilesOverwriteCaveat: 'Os perfis K são a exceção: gravar um slot sempre substitui a calibração na impressora.',
|
||||
tally: '{{restored}} restaurados, {{skipped}} ignorados, {{failed}} com falha',
|
||||
reloadHint: 'Recarregue o Bambuddy para que os dados restaurados apareçam em todos os lugares.',
|
||||
partialHint: 'As categorias listadas acima foram concluídas e estão salvas. As que faltam não chegaram a ser executadas.',
|
||||
failed: 'Falha na restauração.',
|
||||
loadFailed: 'Não foi possível ler o repositório de backup.',
|
||||
details: {
|
||||
|
||||
@@ -4652,6 +4652,7 @@ export default {
|
||||
kprofilesOverwriteCaveat: 'K-профили — исключение: запись в слот всегда заменяет калибровку на принтере.',
|
||||
tally: 'восстановлено: {{restored}}, пропущено: {{skipped}}, с ошибкой: {{failed}}',
|
||||
reloadHint: 'Перезагрузите Bambuddy, чтобы восстановленные данные отобразились везде.',
|
||||
partialHint: 'Перечисленные выше категории завершены и сохранены. Отсутствующие категории не выполнялись.',
|
||||
failed: 'Не удалось выполнить восстановление.',
|
||||
loadFailed: 'Не удалось прочитать репозиторий резервных копий.',
|
||||
details: {
|
||||
|
||||
@@ -4873,6 +4873,7 @@ export default {
|
||||
kprofilesOverwriteCaveat: 'K profilleri istisnadır: bir yuvaya yazmak yazıcıdaki kalibrasyonu her zaman değiştirir.',
|
||||
tally: '{{restored}} geri yüklendi, {{skipped}} atlandı, {{failed}} başarısız',
|
||||
reloadHint: 'Geri yüklenen verilerin her yerde görünmesi için Bambuddy\'yi yeniden yükleyin.',
|
||||
partialHint: 'Yukarıda listelenen kategoriler tamamlandı ve kaydedildi. Eksik olanlar hiç çalıştırılmadı.',
|
||||
failed: 'Geri yükleme başarısız oldu.',
|
||||
loadFailed: 'Yedek deposu okunamadı.',
|
||||
details: {
|
||||
|
||||
@@ -4938,6 +4938,7 @@ export default {
|
||||
kprofilesOverwriteCaveat: 'K-профілі — виняток: запис у слот завжди замінює калібрування на принтері.',
|
||||
tally: "відновлено: {{restored}}, пропущено: {{skipped}}, з помилкою: {{failed}}",
|
||||
reloadHint: "Перезавантажте Bambuddy, щоб відновлені дані відобразилися всюди.",
|
||||
partialHint: 'Перелічені вище категорії завершено та збережено. Відсутні категорії не виконувалися.',
|
||||
failed: "Не вдалося виконати відновлення.",
|
||||
loadFailed: "Не вдалося прочитати репозиторій резервних копій.",
|
||||
details: {
|
||||
|
||||
@@ -4883,6 +4883,7 @@ export default {
|
||||
kprofilesOverwriteCaveat: 'K 值配置是例外:写入插槽总会替换打印机上的校准数据。',
|
||||
tally: '已恢复 {{restored}} 项,跳过 {{skipped}} 项,失败 {{failed}} 项',
|
||||
reloadHint: '请重新加载 Bambuddy,以便恢复的数据在各处生效。',
|
||||
partialHint: '上面列出的类别已完成并已保存。未列出的类别没有执行。',
|
||||
failed: '恢复失败。',
|
||||
loadFailed: '无法读取备份仓库。',
|
||||
details: {
|
||||
|
||||
@@ -4883,6 +4883,7 @@ export default {
|
||||
kprofilesOverwriteCaveat: 'K 值設定檔是例外:寫入插槽一定會取代印表機上的校準資料。',
|
||||
tally: '已還原 {{restored}} 筆、略過 {{skipped}} 筆、失敗 {{failed}} 筆',
|
||||
reloadHint: '請重新載入 Bambuddy,讓還原的資料在各處生效。',
|
||||
partialHint: '上方列出的類別已完成並已儲存。未列出的類別沒有執行。',
|
||||
failed: '還原失敗。',
|
||||
loadFailed: '無法讀取備份儲存庫。',
|
||||
details: {
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
-1
@@ -26,7 +26,7 @@
|
||||
|
||||
<!-- Splash screens for iOS -->
|
||||
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
|
||||
<script type="module" crossorigin src="/assets/index-joRUZURS.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-BPSw6nnF.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-Db2rfQf-.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
Reference in New Issue
Block a user