Browse Source

Let a print with no 3MF be given its filament weight (issue #1820)

When the sliced file stays somewhere Bambuddy cannot read, the archive is
built from the printer's report alone and carries no weight. Nothing could
supply one afterwards: rescan reads the figure out of the 3MF, and that
archive has no file to read. The reporter's H2S print left 46.16 g on the
spool with nothing recording it, and he corrected Spoolman by hand.

Edit Archive now has a Filament used (g) field. It is written to the
archive's most recent run as well, because the Projects roll-up and the
Prometheus counter sum PrintLogEntry rather than the cards - correcting
only the archive would fix the display and leave every aggregate reading
the old figure, or none at all.

But not over a figure the run measured for itself. A run's grams come from
the tracked spool delta when there is one and only fall back to copying
the archive's estimate when there is not, so mirroring unconditionally
would overwrite a measurement with a typed estimate. The mirror now takes
a run that has no figure, or one holding exactly what this archive held -
which also makes the undo complete, since clearing the archive clears the
copy it made and leaves a measured run alone.

The field is text rather than a number input. A number input reports an
empty string for anything the browser judges malformed, a decimal comma in
a locale that does not expect one included, and that reads here as "the
user cleared it" - it would have wiped a good figure while the field still
showed what was typed. Filtering on the way in keeps what is displayed and
what would be sent the same string, and clamps it to the range the API
accepts: this modal has no error surface, so a refused save looks like
nothing happened at all.

Saving also invalidates the archive's runs query. The Print Log this modal
renders at its top reads them separately and kept serving the pre-edit row,
so a correction looked like it had not taken - true for the status and
failure-reason mirrors since #1444 as well.

Second half of the same report: the internal-storage probe (#2856) logged
which file it found but not where. On a printer that keeps uploads for
weeks - the reporter has months of them in /cache - a reprint of a name
that was re-sliced but never re-sent can match an older copy, and without
the directory that mismatch is invisible rather than merely rare. The
download helper returns the path that served the file instead of a bare
flag; every caller only ever tested it for truth.
maziggy 2 weeks ago
parent
commit
d227d42272

+ 2 - 0
CHANGELOG.md

@@ -5,6 +5,8 @@ All notable changes to Bambuddy will be documented in this file.
 ## [1.2.6b1] - Unreleased
 
 ### Fixed
+- **A print archived without its 3MF can be given its filament weight by hand (#1820, reported by @ojimpo)** — When the sliced file stays somewhere Bambuddy cannot read, the archive is created from the printer's report alone and carries no weight, so the print is missing from every filament total and there was no way to put it right afterwards: Rescan reads the figure out of the 3MF, and that archive has no file to read. The reporter's H2S print left 46 g of PLA on the spool with nothing recording it, and he corrected Spoolman by hand. **Edit Archive** now has a **Filament used (g)** field. It is written to the print's most recent run as well as to the archive, because the Projects roll-up and the Prometheus counter sum the runs rather than the cards — correcting only the card would have fixed the display and left every aggregate reading the old figure. The value is bounded at 0 to 100 kg, it is sent only when you actually change it, so an ordinary save cannot round off a sliced figure, and emptying the field clears it. A run that measured its own weight through spool tracking keeps that measurement — the correction fills in a run that has none, or one that only ever inherited the archive's estimate, and never overwrites a real measurement with a typed one. Nothing is deducted from Spoolman or internal inventory either: those are charged from what was tracked at the time, and a print that recorded nothing has nothing to reverse. On an archive that does have its 3MF, Rescan still overwrites what you typed — there the file is the authority. Translated in all locales; wiki updated. Covered by backend and frontend tests.
+- **The internal-storage probe now logs which directory served the file (#1820)** — When a printer says a print went to internal storage and Bambuddy finds it over FTPS anyway (#2856), the log said which file but not where it came from. On a printer that keeps uploads for weeks — the reporter's H2S has months of them in `/cache` — a reprint of a name that was re-sliced but never re-sent can match an older copy, and without the directory in the log that mismatch was invisible rather than merely rare. The download helper now reports the path that served the file instead of a bare success flag, and the hit line names it. Covered by backend tests.
 - **Card and row actions were unreachable on phones and tablets (#2865, reported by @aishlai)** — The three-dot menu on a project card carries Edit and Delete and appeared only on hover, so on a phone there was no way to rename or delete a project at all; the File Manager's folder actions went the same way, as did duplicating a preset, renaming or deleting a tag, removing a print photo and deleting a plate-reference image. This is worse than a missing hover event: Tailwind v4 compiles `hover:` and `group-hover:` inside `@media (hover: hover)`, so on a touch-only device the rule that reveals the control is not merely never triggered, it is never applied — the element stays invisible for good. The hiding half is now what depends on a hover-capable pointer, so a device that cannot hover simply shows the control, and a mouse behaves exactly as before. Four spots on the Archives cards and two on the File Manager's file cards already tried to handle this by viewport width, under 768 pixels, which meant a phone was fine and a tablet in landscape was not; they now use the same capability check and the width guess is gone. Keyboard users were affected too, in the other direction — the buttons were invisible but still focusable, so tabbing through a card stopped on something nobody could see; focus now reveals them. Wiki updated. Covered by frontend tests.
 - **The build plate of a powered-down printer can be cleared again (#2864, reported by @bryanmahin)** — With Auto Power Off enabled this is the ordinary end of every print: the job finishes, Bambuddy switches the printer off at the plug, and the plate is left flagged dirty on a machine that is no longer reachable. The operator then walks over, clears the plate, and had no way to say so — `POST /printers/{id}/clear-plate` answered 400 "Printer not connected", and the button was hidden on the printer card, so the physical clear-plate buttons some farms drive over the API went dead too. Everything gated on the flag stayed stuck until each printer was powered back on by hand, cleared, and switched off again, which is the opposite of what Auto Power Off is for. Nothing in clearing a plate talks to the printer: the flag is Bambuddy's own state, persisted in its database precisely so it survives the power cycle, and the check that refused was inherited from the stop, pause and resume handlers next to it, where reaching the printer genuinely is required. It is gone, and the card offers the control whether the printer is online or not — in both card sizes, and for bulk selections, where powered-down printers were being filtered out of a Clear All. This does not dispatch work to an unreachable machine: the queue still requires a live connection before it sends anything, and releasing the gate is what lets it power the printer back on for the next job instead of skipping it. One related gap went with it — a printer with no live connection at all, disconnected by hand or not yet reconnected after a restart, reported its plate as clean over the API regardless of what the database said, which hid the control on exactly the printers that needed it. Wiki updated. Covered by backend and frontend tests.
 - **H2D archives lost their 3MF, thumbnail and filament data after the last update (#2856, reported by @aishlai)** — When a print starts, the printer says where it put the sliced file, and Bambuddy took `brtc://emmc/<name>` — internal storage — as proof there was nothing to fetch, so it archived the print by name alone. On an H2D with a card in the slot that is not true: the same file sits under `/cache` and downloads without complaint, as it had for that reporter's every print until the change landed. Bambuddy now checks instead of assuming. The printer names the exact file, so confirming it takes one connection across five paths rather than the ~110-connection search that made skipping worth doing — and when the file really is out of reach, as it is on an H2C or P2S with no copy on the card, the archive falls back exactly as before and still says why. The connection diagnostic asks the same question before warning that a print is out of reach. Covered by backend tests.

+ 23 - 2
backend/app/api/routes/archives.py

@@ -1660,7 +1660,7 @@ async def update_archive(
         )
     ),
 ):
-    """Update archive metadata (tags, notes, cost, is_favorite, project_id)."""
+    """Update archive metadata (tags, notes, cost, filament grams, is_favorite, project_id)."""
     from sqlalchemy.orm import selectinload
 
     user, can_modify_all = auth_result
@@ -1679,6 +1679,10 @@ async def update_archive(
         if archive.created_by_id != user.id:
             raise HTTPException(403, "You can only update your own archives")
 
+    # Read before the writes below: the mirror needs to know whether the run's
+    # figure was inherited from this archive or measured on its own (#1820).
+    previous_filament_grams = archive.filament_used_grams
+
     update_payload = update_data.model_dump(exclude_unset=True)
     for field, value in update_payload.items():
         setattr(archive, field, value)
@@ -1693,7 +1697,12 @@ async def update_archive(
     # entry either. Only the latest entry is touched because that's the run
     # the modal is implicitly showing (archive.failure_reason / status are
     # overwritten on each reprint to reflect the latest run's outcome).
-    mirror_fields = {"failure_reason", "status"}
+    # filament_used_grams rides along for the same reason (#1820): the filament
+    # totals on the Projects page and in the Prometheus metrics sum the LOG
+    # ENTRY's grams, not the archive's, so correcting only the archive would fix
+    # the card and leave every aggregate reading the old figure -- or, for a
+    # print that archived without its 3MF, no figure at all.
+    mirror_fields = {"failure_reason", "status", "filament_used_grams"}
     to_mirror = {k: v for k, v in update_payload.items() if k in mirror_fields}
     if to_mirror:
         from backend.app.models.print_log import PrintLogEntry
@@ -1705,6 +1714,18 @@ async def update_archive(
             .limit(1)
         )
         if latest_entry is not None:
+            # ...but never over a figure the run measured for itself. A run's
+            # grams come from the tracked spool delta when there is one, and
+            # only fall back to copying the archive's estimate when there is
+            # not (see _compute_run_filament_grams). Overwriting a measurement
+            # with a typed estimate would lose the better number; the case this
+            # edit exists for -- a print archived with no 3MF -- leaves the run
+            # with nothing at all, so it is covered by the None arm.
+            if "filament_used_grams" in to_mirror and not (
+                latest_entry.filament_used_grams is None or latest_entry.filament_used_grams == previous_filament_grams
+            ):
+                del to_mirror["filament_used_grams"]
+
             for field, value in to_mirror.items():
                 setattr(latest_entry, field, value)
 

+ 6 - 1
backend/app/main.py

@@ -3608,9 +3608,14 @@ async def on_print_start(printer_id: int, data: dict):
                     downloaded_filename = storage.probe_filename
                     temp_path = probe_path
                     cache_3mf_download(printer_id, downloaded_filename, probe_path)
+                    # Naming the path, not just the file: a printer that keeps
+                    # uploads around for weeks can serve a same-named copy of an
+                    # earlier slice, and without the directory in the log that
+                    # mismatch is invisible rather than merely rare (#1820).
                     logger.info(
-                        "Found %s over FTPS for printer %s even though the printer reported %s",
+                        "Found %s at %s over FTPS for printer %s even though the printer reported %s",
                         downloaded_filename,
+                        probe_hit,
                         printer_id,
                         storage.reason,
                     )

+ 7 - 1
backend/app/schemas/archive.py

@@ -1,7 +1,7 @@
 from datetime import datetime
 from typing import Annotated
 
-from pydantic import BaseModel, BeforeValidator, model_validator
+from pydantic import BaseModel, BeforeValidator, Field, model_validator
 
 from backend.app.utils.filename import clean_display_name
 
@@ -28,6 +28,12 @@ class ArchiveUpdate(ArchiveBase):
     project_id: int | None = None
     # Allow changing status (e.g., clearing failed flag)
     status: str | None = None
+    # Editable because a print archived without its 3MF has no figure at all,
+    # and nothing else can supply one after the fact -- rescan needs a file
+    # this archive does not have (#1820). Bounded because it feeds the filament
+    # totals: 100 kg is far past any single print and well short of a value
+    # that would swamp a chart.
+    filament_used_grams: Annotated[float | None, Field(ge=0, le=100_000)] = None
 
 
 class ArchiveDuplicate(BaseModel):

+ 12 - 5
backend/app/services/bambu_ftp.py

@@ -1157,9 +1157,16 @@ async def download_file_try_paths_async(
     socket_timeout: float | None = None,
     printer_model: str | None = None,
     timeout: float = 90.0,
-) -> bool:
+) -> str | None:
     """Try downloading a file from multiple paths using a single connection.
 
+    Returns the path that served the file, or ``None``. The path rather than a
+    bare flag because the caller usually cannot tell afterwards which candidate
+    hit, and on a printer that keeps uploads around for weeks that is the
+    difference between a diagnosable stale-copy match and an invisible one
+    (#1820). Callers testing it for truth are unaffected: a served path is
+    always a non-empty string.
+
     Args:
         socket_timeout: FTP socket timeout for slow connections (e.g., A1 printers)
         printer_model: Printer model for A1-specific workarounds
@@ -1177,7 +1184,7 @@ async def download_file_try_paths_async(
     def _download():
         client = BambuFTPClient(ip_address, access_code, timeout=socket_timeout, printer_model=printer_model)
         if not client.connect():
-            return False
+            return None
 
         try:
             # FileNotOnPrinterError signals "try the next path", not "give up" —
@@ -1186,10 +1193,10 @@ async def download_file_try_paths_async(
             for remote_path in remote_paths:
                 try:
                     if client.download_to_file(remote_path, local_path):
-                        return True
+                        return remote_path
                 except FileNotOnPrinterError:
                     continue
-            return False
+            return None
         finally:
             client.disconnect()
 
@@ -1197,7 +1204,7 @@ async def download_file_try_paths_async(
         return await asyncio.wait_for(loop.run_in_executor(_ftp_executor, _download), timeout=timeout)
     except TimeoutError:
         logger.warning("FTP download_try_paths exceeded its %ss cap for %s (#2572)", timeout, ip_address)
-        return False
+        return None
 
 
 def _upload_deadline(local_path: Path) -> float:

+ 129 - 0
backend/tests/integration/test_archives_api.py

@@ -287,6 +287,135 @@ class TestArchivesAPI:
         assert response.status_code == 200
         assert response.json()["external_url"] is None
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_filament_grams_can_be_set_by_hand(
+        self, async_client: AsyncClient, archive_factory, printer_factory, db_session
+    ):
+        """#1820: a print that archived without its 3MF has no filament figure
+        and no way to recover one — rescan needs a file this archive does not
+        have. The edit is the only route, so it has to reach the log entry too:
+        the Projects roll-up and the Prometheus counter sum PrintLogEntry, not
+        the archive.
+        """
+        from sqlalchemy import select
+
+        from backend.app.models.print_log import PrintLogEntry
+
+        printer = await printer_factory()
+        archive = await archive_factory(printer.id, print_name="JOB_C")
+
+        response = await async_client.patch(
+            f"/api/v1/archives/{archive.id}",
+            json={"filament_used_grams": 46.16},
+        )
+
+        assert response.status_code == 200, response.text
+        assert response.json()["filament_used_grams"] == 46.16
+
+        mirrored = (
+            await db_session.execute(select(PrintLogEntry).where(PrintLogEntry.archive_id == archive.id))
+        ).scalar_one()
+        assert mirrored.filament_used_grams == 46.16
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_clearing_the_figure_takes_the_mirrored_copy_with_it(
+        self, async_client: AsyncClient, archive_factory, printer_factory, db_session
+    ):
+        """Undo has to be as complete as the correction: a run that only holds
+        the figure because this archive gave it one must not keep it after the
+        archive's is cleared, or the totals stay wrong with nothing on the card
+        to explain them."""
+        from sqlalchemy import select
+
+        from backend.app.models.print_log import PrintLogEntry
+
+        printer = await printer_factory()
+        archive = await archive_factory(printer.id, filament_used_grams=None)
+        entry = (
+            await db_session.execute(select(PrintLogEntry).where(PrintLogEntry.archive_id == archive.id))
+        ).scalar_one()
+        assert entry.filament_used_grams is None
+
+        await async_client.patch(f"/api/v1/archives/{archive.id}", json={"filament_used_grams": 46.16})
+        await db_session.refresh(entry)
+        assert entry.filament_used_grams == 46.16
+
+        response = await async_client.patch(f"/api/v1/archives/{archive.id}", json={"filament_used_grams": None})
+
+        assert response.status_code == 200, response.text
+        await db_session.refresh(entry)
+        assert entry.filament_used_grams is None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_a_measured_run_keeps_its_own_filament_figure(
+        self, async_client: AsyncClient, archive_factory, printer_factory, db_session
+    ):
+        """A run whose grams came from the tracked spool delta measured itself.
+        The archive's number is an estimate, so an edit of the estimate must not
+        overwrite the measurement — the mirror only fills in a run that has no
+        figure, or one that was copied from this archive in the first place.
+        """
+        from sqlalchemy import select
+
+        from backend.app.models.print_log import PrintLogEntry
+
+        printer = await printer_factory()
+        archive = await archive_factory(printer.id, filament_used_grams=50.0)
+        entry = (
+            await db_session.execute(select(PrintLogEntry).where(PrintLogEntry.archive_id == archive.id))
+        ).scalar_one()
+        entry.filament_used_grams = 48.2  # what the spool actually lost
+        await db_session.commit()
+
+        response = await async_client.patch(
+            f"/api/v1/archives/{archive.id}",
+            json={"filament_used_grams": 61.0},
+        )
+
+        assert response.status_code == 200, response.text
+        assert response.json()["filament_used_grams"] == 61.0
+
+        await db_session.refresh(entry)
+        assert entry.filament_used_grams == 48.2
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_filament_grams_refuses_a_negative_figure(
+        self, async_client: AsyncClient, archive_factory, printer_factory, db_session
+    ):
+        """It feeds the filament totals, so a value that would subtract from
+        them is rejected rather than stored."""
+        printer = await printer_factory()
+        archive = await archive_factory(printer.id)
+
+        response = await async_client.patch(
+            f"/api/v1/archives/{archive.id}",
+            json={"filament_used_grams": -5},
+        )
+
+        assert response.status_code == 422, response.text
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_an_edit_that_leaves_filament_grams_alone_does_not_clear_it(
+        self, async_client: AsyncClient, archive_factory, printer_factory, db_session
+    ):
+        """The field is optional on the schema, so an ordinary save of some
+        other field must not read as "set grams to null"."""
+        printer = await printer_factory()
+        archive = await archive_factory(printer.id, filament_used_grams=12.5)
+
+        response = await async_client.patch(
+            f"/api/v1/archives/{archive.id}",
+            json={"notes": "left the grams alone"},
+        )
+
+        assert response.status_code == 200, response.text
+        assert response.json()["filament_used_grams"] == 12.5
+
     @pytest.mark.asyncio
     @pytest.mark.integration
     async def test_update_archive_failure_reason_mirrors_to_print_log_entry(

+ 4 - 2
backend/tests/unit/services/test_bambu_ftp.py

@@ -1027,7 +1027,9 @@ class TestAsyncWrappers:
             local,
             printer_model="X1C",
         )
-        assert result is True
+        # The path, not a flag: the caller logs which candidate served the file
+        # so a stale same-named copy is diagnosable (#1820).
+        assert result == "/cache/try1.bin"
         assert local.read_bytes() == b"first path"
 
     @pytest.mark.asyncio
@@ -1043,7 +1045,7 @@ class TestAsyncWrappers:
             local,
             printer_model="X1C",
         )
-        assert result is True
+        assert result == "/cache/second.bin"
         assert local.read_bytes() == b"second path"
 
     @pytest.mark.asyncio

+ 31 - 6
backend/tests/unit/test_internal_storage_probe_2856.py

@@ -15,6 +15,7 @@ tests pin both halves: the probe runs and its hit is archived normally, and a
 miss still ends in #2780's cheap fallback with its reason intact.
 """
 
+import logging
 from pathlib import Path
 from unittest.mock import AsyncMock, MagicMock, patch
 
@@ -252,7 +253,11 @@ def _printer():
 
 async def _run_print_start(url, *, probe_hit, added, handshake_blocked=False):
     """Drive on_print_start for an eMMC dispatch, returning the probe mock and
-    the ArchiveService the success path would have used."""
+    the ArchiveService the success path would have used.
+
+    ``probe_hit`` is the path the probe serves the file from, or None for a
+    miss — the download helper returns the winning path rather than a flag so
+    the hit can be logged by directory (#1820)."""
     printer = _printer()
     state = MagicMock(current_project_url=url, sdcard=True, sdcard_reported=True)
 
@@ -336,7 +341,9 @@ class TestPrintStart:
         the card, and the archive gets the real 3MF instead of a name."""
         added = []
 
-        probe, service, cache = await _run_print_start("brtc://emmc/test.gcode.3mf", probe_hit=True, added=added)
+        probe, service, cache = await _run_print_start(
+            "brtc://emmc/test.gcode.3mf", probe_hit="/cache/test.gcode.3mf", added=added
+        )
 
         probe.assert_awaited_once()
         assert probe.await_args.args[2] == ftp_probe_paths("test.gcode.3mf")
@@ -344,6 +351,20 @@ class TestPrintStart:
         assert Path(service.archive_print.await_args.kwargs["source_file"]).name == "test.gcode.3mf"
         assert _fallback(added) is None, "a fallback archive here is the bug"
 
+    @pytest.mark.asyncio
+    async def test_the_hit_names_the_directory_that_served_it(self, caplog):
+        """#1820: a printer that keeps uploads for weeks can serve a same-named
+        copy of an earlier slice. Logging only the filename made that mismatch
+        invisible; the directory is what makes it diagnosable."""
+        added = []
+
+        with caplog.at_level(logging.INFO, logger="backend.app.main"):
+            await _run_print_start("brtc://emmc/test.gcode.3mf", probe_hit="/cache/test.gcode.3mf", added=added)
+
+        found = [r.getMessage() for r in caplog.records if "even though the printer reported" in r.getMessage()]
+        assert found, "the probe hit is not logged at all"
+        assert "/cache/test.gcode.3mf" in found[0]
+
     @pytest.mark.asyncio
     async def test_the_probed_file_is_shared_with_the_cover_endpoint(self):
         """Same 3MF, one transfer. The cover endpoint runs seconds later while
@@ -351,7 +372,9 @@ class TestPrintStart:
         single FTP socket is what produced #972's 425 storm."""
         added = []
 
-        _probe, _service, cache = await _run_print_start("brtc://emmc/test.gcode.3mf", probe_hit=True, added=added)
+        _probe, _service, cache = await _run_print_start(
+            "brtc://emmc/test.gcode.3mf", probe_hit="/cache/test.gcode.3mf", added=added
+        )
 
         cache.assert_called_once()
         assert cache.call_args.args[1] == "test.gcode.3mf"
@@ -364,7 +387,7 @@ class TestPrintStart:
         """
         added = []
 
-        probe, service, _cache = await _run_print_start("brtc://emmc/test.gcode.3mf", probe_hit=False, added=added)
+        probe, service, _cache = await _run_print_start("brtc://emmc/test.gcode.3mf", probe_hit=None, added=added)
 
         probe.assert_awaited_once()
         service.archive_print.assert_not_awaited()
@@ -377,7 +400,7 @@ class TestPrintStart:
         added = []
 
         probe, _service, _cache = await _run_print_start(
-            "brtc://emmc/test.gcode.3mf", probe_hit=True, added=added, handshake_blocked=True
+            "brtc://emmc/test.gcode.3mf", probe_hit="/cache/test.gcode.3mf", added=added, handshake_blocked=True
         )
 
         probe.assert_not_awaited()
@@ -389,7 +412,9 @@ class TestPrintStart:
         stays exactly as cheap as #2780 made it."""
         added = []
 
-        probe, _service, _cache = await _run_print_start("brtc://emmc/plate_1.gcode", probe_hit=True, added=added)
+        probe, _service, _cache = await _run_print_start(
+            "brtc://emmc/plate_1.gcode", probe_hit="/cache/test.gcode.3mf", added=added
+        )
 
         probe.assert_not_awaited()
         assert _fallback(added) is not None

+ 4 - 3
backend/tests/unit/test_print_start_skips_unreachable_storage_2780.py

@@ -82,12 +82,13 @@ def _state(current_project_url, sdcard=True, sdcard_reported=True):
     )
 
 
-async def _run_print_start(state, added, probe_hit=False):
+async def _run_print_start(state, added, probe_hit=None):
     """Drive on_print_start for a print with no matching archive, capturing
     whatever rows it adds and whether it reached the FTP layer.
 
-    ``probe_hit`` is what the bounded internal-storage probe finds (#2856);
-    False is the #2780 case where the file really is out of reach.
+    ``probe_hit`` is the path the bounded internal-storage probe serves the
+    file from (#2856), or None for the #2780 case where the file really is out
+    of reach.
     """
     printer = _printer()
 

+ 134 - 0
frontend/src/__tests__/components/EditArchiveModal.test.tsx

@@ -253,4 +253,138 @@ describe('EditArchiveModal', () => {
       });
     });
   });
+
+  describe('filament grams (#1820)', () => {
+    // A print archived without its 3MF carries no weight at all, and no rescan
+    // can supply one — there is no file to read. Typing it here is the only
+    // route, so the field has to reach the API, and an untouched save must not
+    // overwrite a figure that came from a real slice.
+
+    function patchSpy() {
+      const seen: { body?: Record<string, unknown> } = {};
+      server.use(
+        http.patch('/api/v1/archives/:id', async ({ request }) => {
+          seen.body = (await request.json()) as Record<string, unknown>;
+          return HttpResponse.json({ ...mockArchive, ...seen.body });
+        }),
+      );
+      return seen;
+    }
+
+    it('sends a figure typed for an archive that has none', async () => {
+      const user = userEvent.setup();
+      const seen = patchSpy();
+
+      render(<EditArchiveModal archive={mockArchive} onClose={mockOnClose} onSave={mockOnSave} />);
+      await user.type(screen.getByLabelText(/filament used/i), '46.16');
+      await user.click(screen.getByRole('button', { name: /save/i }));
+
+      await waitFor(() => {
+        expect(seen.body?.filament_used_grams).toBe(46.16);
+      });
+    });
+
+    it('leaves the field out of a save that did not touch it', async () => {
+      const user = userEvent.setup();
+      const seen = patchSpy();
+      const weighed = { ...mockArchive, filament_used_grams: 50 };
+
+      render(<EditArchiveModal archive={weighed} onClose={mockOnClose} onSave={mockOnSave} />);
+      await user.click(screen.getByRole('button', { name: /save/i }));
+
+      await waitFor(() => {
+        expect(seen.body).toBeDefined();
+      });
+      expect(seen.body).not.toHaveProperty('filament_used_grams');
+    });
+
+    it('accepts a decimal comma, which a number input would have swallowed', async () => {
+      const user = userEvent.setup();
+      const seen = patchSpy();
+
+      render(<EditArchiveModal archive={mockArchive} onClose={mockOnClose} onSave={mockOnSave} />);
+      await user.type(screen.getByLabelText(/filament used/i), '46,16');
+      await user.click(screen.getByRole('button', { name: /save/i }));
+
+      await waitFor(() => {
+        expect(seen.body?.filament_used_grams).toBe(46.16);
+      });
+    });
+
+    it('refuses characters that could never reach the API as a number', async () => {
+      const user = userEvent.setup();
+
+      render(<EditArchiveModal archive={mockArchive} onClose={mockOnClose} onSave={mockOnSave} />);
+      const field = screen.getByLabelText(/filament used/i) as HTMLInputElement;
+      await user.type(field, '4a6-1..2');
+
+      expect(field.value).toBe('461.2');
+    });
+
+    it('clamps to the bound the API enforces, so a save cannot be refused', async () => {
+      const user = userEvent.setup();
+      const seen = patchSpy();
+
+      render(<EditArchiveModal archive={mockArchive} onClose={mockOnClose} onSave={mockOnSave} />);
+      await user.type(screen.getByLabelText(/filament used/i), '999999');
+      await user.click(screen.getByRole('button', { name: /save/i }));
+
+      await waitFor(() => {
+        expect(seen.body?.filament_used_grams).toBe(100000);
+      });
+    });
+
+    it('does not read a half-typed value as a clear', async () => {
+      // Enter submits without the field ever losing focus, so the blur-time
+      // tidy-up has not run and the state still holds what was typed.
+      const user = userEvent.setup();
+      const seen = patchSpy();
+      const weighed = { ...mockArchive, filament_used_grams: 50 };
+
+      render(<EditArchiveModal archive={weighed} onClose={mockOnClose} onSave={mockOnSave} />);
+      const field = screen.getByLabelText(/filament used/i);
+      await user.clear(field);
+      await user.type(field, '.{Enter}');
+
+      await waitFor(() => {
+        expect(seen.body).toBeDefined();
+      });
+      expect(seen.body).not.toHaveProperty('filament_used_grams');
+    });
+
+    it('refreshes the print log, which the mirrored figure lands in', async () => {
+      const user = userEvent.setup();
+      let runFetches = 0;
+      server.use(
+        http.get('/api/v1/archives/:id/runs', () => {
+          runFetches += 1;
+          return HttpResponse.json({ items: [], total: 0 });
+        }),
+      );
+
+      render(<EditArchiveModal archive={mockArchive} onClose={mockOnClose} onSave={mockOnSave} />);
+      await waitFor(() => expect(runFetches).toBe(1));
+
+      await user.type(screen.getByLabelText(/filament used/i), '46.16');
+      await user.click(screen.getByRole('button', { name: /save/i }));
+
+      // Without the invalidation the table keeps serving its cached rows, so
+      // the run the edit just corrected still shows the old figure.
+      await waitFor(() => expect(runFetches).toBe(2));
+    });
+
+    it('clears the figure when the field is emptied', async () => {
+      const user = userEvent.setup();
+      const seen = patchSpy();
+      const weighed = { ...mockArchive, filament_used_grams: 50 };
+
+      render(<EditArchiveModal archive={weighed} onClose={mockOnClose} onSave={mockOnSave} />);
+      await user.clear(screen.getByLabelText(/filament used/i));
+      await user.click(screen.getByRole('button', { name: /save/i }));
+
+      await waitFor(() => {
+        expect(seen.body?.filament_used_grams).toBeNull();
+      });
+    });
+  });
 });

+ 1 - 0
frontend/src/api/client.ts

@@ -4809,6 +4809,7 @@ export const api = {
     status?: string;
     quantity?: number;
     external_url?: string | null;
+    filament_used_grams?: number | null;
   }) =>
     request<Archive>(`/archives/${id}`, {
       method: 'PATCH',

+ 72 - 1
frontend/src/components/EditArchiveModal.tsx

@@ -1,7 +1,7 @@
 import { useState, useEffect, useRef } from 'react';
 import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
 import { useTranslation } from 'react-i18next';
-import { X, Save, Tag, Camera, Trash2, Loader2, Plus, FolderKanban, Hash, Link } from 'lucide-react';
+import { X, Save, Tag, Camera, Trash2, Loader2, Plus, FolderKanban, Hash, Link, Weight } from 'lucide-react';
 import { api } from '../api/client';
 import type { Archive } from '../api/client';
 import { Button } from './Button';
@@ -29,6 +29,12 @@ export const FAILURE_REASON_KEYS = [
 // Keys for archive statuses - translated at render time
 const ARCHIVE_STATUS_KEYS = ['completed', 'failed', 'aborted', 'printing'] as const;
 
+// Mirrors the API's own bound on filament_used_grams. Clamped here as well so
+// the field cannot produce a request the backend would reject with a 422 —
+// this modal has no error surface, so a refused save looks like nothing
+// happened at all (#1820).
+const MAX_FILAMENT_GRAMS = 100000;
+
 interface EditArchiveModalProps {
   archive: Archive;
   onClose: () => void;
@@ -67,6 +73,12 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
   });
   const [status, setStatus] = useState(archive.status);
   const [quantity, setQuantity] = useState(archive.quantity ?? 1);
+  // Kept as a string so the field can be genuinely empty: a print archived
+  // without its 3MF has no figure at all, and "" has to stay distinguishable
+  // from 0 both on the way in and on the way out (#1820).
+  const [filamentGrams, setFilamentGrams] = useState(
+    archive.filament_used_grams != null ? String(archive.filament_used_grams) : ''
+  );
   const [photos, setPhotos] = useState<string[]>(archive.photos || []);
   const [externalUrl, setExternalUrl] = useState(archive.external_url || '');
   const [uploadingPhoto, setUploadingPhoto] = useState(false);
@@ -151,6 +163,12 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
       // This form can change the archive's project, so the project detail
       // views need refreshing too — not just the overview cards (#2731).
       invalidateArchiveAndProjectViews(queryClient);
+      // Some of what this form writes is mirrored onto the archive's most
+      // recent run — status and failure reason since #1444, filament grams
+      // since #1820 — and the Print Log this modal renders at its top reads
+      // the runs through their own query. Without this it serves the cached
+      // pre-edit row, so the correction looks like it did not take.
+      queryClient.invalidateQueries({ queryKey: ['archive-runs', archive.id] });
       onClose();
     },
   });
@@ -202,6 +220,22 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
       updateData.status = status;
     }
 
+    // Sent only when the user actually touched it, so an ordinary save of an
+    // archive that has its 3MF cannot overwrite the sliced figure with a
+    // rounded one from the input.
+    const trimmedGrams = filamentGrams.trim();
+    const typedGrams = trimmedGrams === '' ? null : Number(trimmedGrams);
+    // An empty field means "no figure" and clears the stored one; a field that
+    // holds something unparseable (a lone decimal point, mid-typing) means the
+    // user is not finished, and must not read as a clear. Clamped here as well
+    // as on blur because Enter submits without the field losing focus.
+    const gramsUnparseable = typedGrams !== null && !Number.isFinite(typedGrams);
+    const parsedGrams = typedGrams === null ? null : Math.min(Math.max(typedGrams, 0), MAX_FILAMENT_GRAMS);
+    const originalGrams = archive.filament_used_grams ?? null;
+    if (!gramsUnparseable && parsedGrams !== originalGrams) {
+      updateData.filament_used_grams = parsedGrams;
+    }
+
     // Handle failure_reason based on status
     if (status === 'failed' || status === 'aborted') {
       updateData.failure_reason = failureReason || undefined;
@@ -310,6 +344,43 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
             </p>
           </div>
 
+          {/* Filament used - the only way to supply a figure for a print that
+              archived without its 3MF, which no rescan can repair (#1820). */}
+          <div>
+            <label className="block text-sm text-bambu-gray mb-1" htmlFor="archive-filament-grams">
+              <Weight className="w-4 h-4 inline mr-1" />
+              {t('editArchive.filamentUsed')}
+            </label>
+            <input
+              id="archive-filament-grams"
+              type="text"
+              inputMode="decimal"
+              value={filamentGrams}
+              // Text rather than number, and filtered on the way in. A number
+              // input reports an empty string for anything the browser judges
+              // malformed — including a decimal comma in a locale it doesn't
+              // expect — which would read here as "the user cleared it" and
+              // wipe a good figure. Filtering keeps what is displayed and what
+              // would be sent the same thing, and keeps the value inside the
+              // range the API accepts: this modal shows nothing at all when a
+              // save is refused, so it must not be able to send a refusable one.
+              onChange={(e) => {
+                const next = e.target.value.replace(',', '.');
+                if (next === '' || /^\d*\.?\d*$/.test(next)) setFilamentGrams(next);
+              }}
+              onBlur={() => setFilamentGrams((current) => {
+                const parsed = Number(current);
+                if (current === '' || !Number.isFinite(parsed)) return '';
+                return String(Math.min(parsed, MAX_FILAMENT_GRAMS));
+              })}
+              className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
+              placeholder={t('editArchive.filamentUsedPlaceholder')}
+            />
+            <p className="text-xs text-bambu-gray mt-1">
+              {t('editArchive.filamentUsedHelp')}
+            </p>
+          </div>
+
           {/* Notes */}
           <div>
             <label className="block text-sm text-bambu-gray mb-1">{t('editArchive.notes')}</label>

+ 3 - 0
frontend/src/i18n/locales/de.ts

@@ -5309,6 +5309,9 @@ export default {
     noProject: 'Kein Projekt',
     itemsPrinted: 'Gedruckte Teile',
     itemsPrintedHelp: 'Anzahl der in diesem Druckauftrag produzierten Teile',
+    filamentUsed: 'Verbrauchtes Filament (g)',
+    filamentUsedPlaceholder: 'z. B. 46.16',
+    filamentUsedHelp: 'Von Hand eintragen, wenn ein Druck ohne 3MF archiviert wurde, damit er in die Filament-Summen eingeht. Beim erneuten Einlesen eines Archivs mit 3MF wird der Wert wieder aus der Datei übernommen.',
     notes: 'Notizen',
     notesPlaceholder: 'Notizen zu diesem Druck hinzufügen...',
     externalLink: 'Externer Link',

+ 3 - 0
frontend/src/i18n/locales/en.ts

@@ -5359,6 +5359,9 @@ export default {
     noProject: 'No project',
     itemsPrinted: 'Items Printed',
     itemsPrintedHelp: 'Number of items produced in this print job',
+    filamentUsed: 'Filament used (g)',
+    filamentUsedPlaceholder: 'e.g. 46.16',
+    filamentUsedHelp: 'Set this by hand when a print archived without its 3MF, so it still counts towards the filament totals. Rescanning an archive that has its 3MF reads the figure back from the file.',
     notes: 'Notes',
     notesPlaceholder: 'Add notes about this print...',
     externalLink: 'External Link',

+ 3 - 0
frontend/src/i18n/locales/es.ts

@@ -5317,6 +5317,9 @@ export default {
     noProject: 'Sin proyecto',
     itemsPrinted: 'Elementos impresos',
     itemsPrintedHelp: 'Número de elementos producidos en este trabajo de impresión',
+    filamentUsed: 'Filamento usado (g)',
+    filamentUsedPlaceholder: 'p. ej. 46.16',
+    filamentUsedHelp: 'Introdúcelo a mano cuando una impresión se haya archivado sin su 3MF, para que siga contando en los totales de filamento. Al volver a escanear un archivo que sí tiene su 3MF, el valor se lee de nuevo del fichero.',
     notes: 'Notas',
     notesPlaceholder: 'Añada notas sobre esta impresión...',
     externalLink: 'Enlace externo',

+ 3 - 0
frontend/src/i18n/locales/fr.ts

@@ -5299,6 +5299,9 @@ export default {
     noProject: 'Aucun projet',
     itemsPrinted: 'Nombre de pièces',
     itemsPrintedHelp: 'Nombre d\'objets produits',
+    filamentUsed: 'Filament utilisé (g)',
+    filamentUsedPlaceholder: 'p. ex. 46.16',
+    filamentUsedHelp: 'À saisir à la main lorsqu\'une impression a été archivée sans son 3MF, afin qu\'elle compte quand même dans les totaux de filament. Une nouvelle analyse d\'une archive qui possède son 3MF relit la valeur depuis le fichier.',
     notes: 'Notes',
     notesPlaceholder: 'Notes sur l\'impression...',
     externalLink: 'Lien externe',

+ 3 - 0
frontend/src/i18n/locales/it.ts

@@ -5298,6 +5298,9 @@ export default {
     noProject: 'Nessun progetto',
     itemsPrinted: 'Elementi stampati',
     itemsPrintedHelp: 'Numero di elementi prodotti in questo job di stampa',
+    filamentUsed: 'Filamento usato (g)',
+    filamentUsedPlaceholder: 'es. 46.16',
+    filamentUsedHelp: 'Inseriscilo a mano quando una stampa è stata archiviata senza il suo 3MF, così da farla rientrare comunque nei totali del filamento. La riscansione di un archivio che ha il suo 3MF rilegge il valore dal file.',
     notes: 'Note',
     notesPlaceholder: 'Aggiungi note su questa stampa...',
     externalLink: 'Link esterno',

+ 3 - 0
frontend/src/i18n/locales/ja.ts

@@ -5310,6 +5310,9 @@ export default {
     noProject: 'プロジェクトなし',
     itemsPrinted: '印刷数',
     itemsPrintedHelp: 'この印刷ジョブで製造したアイテム数',
+    filamentUsed: '使用フィラメント (g)',
+    filamentUsedPlaceholder: '例: 46.16',
+    filamentUsedHelp: '3MF なしでアーカイブされた印刷は、手入力するとフィラメント合計に反映されます。3MF があるアーカイブを再スキャンすると、この値はファイルから読み直されます。',
     notes: 'メモ',
     notesPlaceholder: 'この印刷についてメモを追加...',
     externalLink: '外部リンク',

+ 3 - 0
frontend/src/i18n/locales/ko.ts

@@ -5059,6 +5059,9 @@ export default {
     noProject: '프로젝트 없음',
     itemsPrinted: '인쇄된 항목',
     itemsPrintedHelp: '이 인쇄 작업에서 생산된 항목 수',
+    filamentUsed: '사용된 필라멘트 (g)',
+    filamentUsedPlaceholder: '예: 46.16',
+    filamentUsedHelp: '3MF 없이 보관된 인쇄는 직접 입력하면 필라멘트 합계에 반영됩니다. 3MF가 있는 보관 항목을 다시 스캔하면 이 값은 파일에서 다시 읽어옵니다.',
     notes: '메모',
     notesPlaceholder: '이 인쇄에 대한 메모 추가...',
     externalLink: '외부 링크',

+ 3 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -5298,6 +5298,9 @@ export default {
     noProject: 'Nenhum projeto',
     itemsPrinted: 'Itens Impressos',
     itemsPrintedHelp: 'Número de itens produzidos neste trabalho de impressão',
+    filamentUsed: 'Filamento usado (g)',
+    filamentUsedPlaceholder: 'ex.: 46.16',
+    filamentUsedHelp: 'Informe manualmente quando uma impressão for arquivada sem o 3MF, para que ela ainda conte nos totais de filamento. Reescanear um arquivo que tem o 3MF lê o valor novamente do arquivo.',
     notes: 'Notas',
     notesPlaceholder: 'Adicione notas sobre esta impressão...',
     externalLink: 'Link Externo',

+ 3 - 0
frontend/src/i18n/locales/ru.ts

@@ -5047,6 +5047,9 @@ export default {
     noProject: "Без проекта",
     itemsPrinted: "Напечатано изделий",
     itemsPrintedHelp: "Количество изделий, полученных в этом задании печати",
+    filamentUsed: "Израсходованный филамент (г)",
+    filamentUsedPlaceholder: "напр. 46.16",
+    filamentUsedHelp: "Укажите вручную, если печать заархивирована без 3MF — тогда она попадёт в итоги по филаменту. При повторном сканировании архива с 3MF значение снова считывается из файла.",
     notes: "Заметки",
     notesPlaceholder: "Добавьте заметки об этой печати...",
     externalLink: "Внешняя ссылка",

+ 3 - 0
frontend/src/i18n/locales/tr.ts

@@ -5283,6 +5283,9 @@ export default {
     noProject: 'Proje yok',
     itemsPrinted: 'Yazdırılan Öğeler',
     itemsPrintedHelp: 'Bu baskı işinde üretilen öğe sayısı',
+    filamentUsed: 'Kullanılan filament (g)',
+    filamentUsedPlaceholder: 'ör. 46.16',
+    filamentUsedHelp: 'Bir baskı 3MF dosyası olmadan arşivlendiyse elle girin; böylece filament toplamlarına yine de dahil olur. 3MF dosyası olan bir arşiv yeniden tarandığında bu değer dosyadan yeniden okunur.',
     notes: 'Notlar',
     notesPlaceholder: 'Bu baskı hakkında notlar ekleyin...',
     externalLink: 'Harici Bağlantı',

+ 3 - 0
frontend/src/i18n/locales/uk.ts

@@ -5352,6 +5352,9 @@ export default {
     noProject: "Жодного проєкту",
     itemsPrinted: "Надруковано об’єктів",
     itemsPrintedHelp: "Кількість елементів, виготовлених у цьому завданні друку",
+    filamentUsed: "Витрачений філамент (г)",
+    filamentUsedPlaceholder: "напр. 46.16",
+    filamentUsedHelp: "Вкажіть вручну, якщо друк заархівовано без 3MF — тоді він потрапить до підсумків з філаменту. Повторне сканування архіву, який має 3MF, зчитує значення з файлу.",
     notes: "Примітки",
     notesPlaceholder: "Додайте примітки щодо цього друку...",
     externalLink: "Зовнішнє посилання",

+ 3 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -5298,6 +5298,9 @@ export default {
     noProject: '无项目',
     itemsPrinted: '打印数量',
     itemsPrintedHelp: '此打印任务中生产的物品数量',
+    filamentUsed: '已用耗材 (g)',
+    filamentUsedPlaceholder: '例如 46.16',
+    filamentUsedHelp: '当打印任务在没有 3MF 的情况下归档时,可手动填写,使其仍计入耗材总量。重新扫描带有 3MF 的归档时,该数值会从文件中重新读取。',
     notes: '备注',
     notesPlaceholder: '添加关于此打印的备注...',
     externalLink: '外部链接',

+ 3 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -5298,6 +5298,9 @@ export default {
     noProject: '無專案',
     itemsPrinted: '列印數量',
     itemsPrintedHelp: '此列印任務中生產的物品數量',
+    filamentUsed: '已用耗材 (g)',
+    filamentUsedPlaceholder: '例如 46.16',
+    filamentUsedHelp: '當列印工作在沒有 3MF 的情況下封存時,可手動填寫,使其仍計入耗材總量。重新掃描具有 3MF 的封存項目時,此數值會從檔案重新讀取。',
     notes: '備註',
     notesPlaceholder: '新增關於此列印的備註...',
     externalLink: '外部連結',

File diff suppressed because it is too large
+ 0 - 0
static/assets/index-CweAJSEj.js


+ 1 - 1
static/index.html

@@ -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-LeHzL3OL.js"></script>
+    <script type="module" crossorigin src="/assets/index-CweAJSEj.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-kSJGQrMr.css">
   </head>
   <body>

Some files were not shown because too many files changed in this diff