Explorar o código

Archives: a file printed again adds a run to its archive

- A queue print of a File Manager or project file joins the file's
  existing archive (same content on disk, owner, project, and library
  file or none) instead of archiving the file again
- Only an idle archive is taken: no pending/printing queue item on it,
  not printing, not claimed by a parallel upload, and no timelapse scan
  or stitch of its last run still going
- print_log_entries.plate_id: each run keeps the plate it printed,
  written at completion and offline stop, backfilled once from the queue
- The archive shows its latest run's plate (reprints too), and the card
  that plate's picture; the Print Log gets a Plate column
maziggy hai 1 día
pai
achega
a03470e6aa
Modificáronse 82 ficheiros con 961 adicións e 37 borrados
  1. 1 0
      CHANGELOG.md
  2. 1 0
      backend/app/api/routes/print_queue.py
  3. 45 0
      backend/app/core/database.py
  4. 44 7
      backend/app/main.py
  5. 4 0
      backend/app/models/print_log.py
  6. 1 0
      backend/app/schemas/print_log.py
  7. 11 1
      backend/app/services/print_log.py
  8. 118 16
      backend/app/services/print_scheduler.py
  9. 16 0
      backend/tests/integration/test_print_log_one_entry_per_run.py
  10. 32 0
      backend/tests/integration/test_print_queue_api.py
  11. 86 0
      backend/tests/unit/test_archive_run_finishing.py
  12. 1 0
      backend/tests/unit/test_forget_printer.py
  13. 334 0
      backend/tests/unit/test_library_dispatch_reuses_archive.py
  14. 116 0
      backend/tests/unit/test_print_log_plate_backfill.py
  15. 63 7
      backend/tests/unit/test_run_estimate_plate.py
  16. 31 0
      frontend/src/__tests__/components/PrintLogModal.test.tsx
  17. 15 0
      frontend/src/__tests__/pages/ArchivesPage.test.tsx
  18. 1 0
      frontend/src/api/client.ts
  19. 9 0
      frontend/src/components/PrintLogTable.tsx
  20. 1 0
      frontend/src/i18n/locales/de.ts
  21. 1 0
      frontend/src/i18n/locales/en.ts
  22. 1 0
      frontend/src/i18n/locales/es.ts
  23. 1 0
      frontend/src/i18n/locales/fr.ts
  24. 1 0
      frontend/src/i18n/locales/it.ts
  25. 1 0
      frontend/src/i18n/locales/ja.ts
  26. 2 1
      frontend/src/i18n/locales/ko.ts
  27. 1 0
      frontend/src/i18n/locales/nl.ts
  28. 1 0
      frontend/src/i18n/locales/pt-BR.ts
  29. 1 0
      frontend/src/i18n/locales/ru.ts
  30. 1 0
      frontend/src/i18n/locales/sv.ts
  31. 1 0
      frontend/src/i18n/locales/tr.ts
  32. 1 0
      frontend/src/i18n/locales/uk.ts
  33. 1 0
      frontend/src/i18n/locales/zh-CN.ts
  34. 1 0
      frontend/src/i18n/locales/zh-TW.ts
  35. 12 1
      frontend/src/pages/ArchivesPage.tsx
  36. 0 0
      static/assets/ArchivesPage-QsB62ebu.js
  37. 0 0
      static/assets/CamWallPage-DDwb0P0U.js
  38. 0 0
      static/assets/CameraPage-Dr1P9O85.js
  39. 0 0
      static/assets/ConfirmOutcomeDialog-C2TGy01-.js
  40. 0 0
      static/assets/ConfirmOutcomeDialog-DQcbXiuf.js
  41. 0 0
      static/assets/FileManagerPage-DpDGTgZx.js
  42. 0 0
      static/assets/InventoryPage-DQWlUrqc.js
  43. 0 0
      static/assets/Layout-B1sOAU5B.js
  44. 0 0
      static/assets/ModelSourcesPage-ChL0-1s3.js
  45. 0 0
      static/assets/PhotoGalleryModal-BuupV3Ho.js
  46. 0 0
      static/assets/PrintersPage-Bzhc9aaH.js
  47. 0 0
      static/assets/SettingsPage-CsLdtL8p.js
  48. 0 0
      static/assets/SliceModal-8vVtLR8-.js
  49. 0 0
      static/assets/SpoolBuddyInventoryPage--qzMHNDp.js
  50. 1 1
      static/assets/SpoolBuddySettingsPage-uoy_ItK2.js
  51. 0 0
      static/assets/de-Bk0IiWpM.js
  52. 0 0
      static/assets/de-CkWtaO_K.js
  53. 0 0
      static/assets/es-BjYoCL6b.js
  54. 0 0
      static/assets/es-CWw9g0wR.js
  55. 0 0
      static/assets/fr-8h712JuF.js
  56. 0 0
      static/assets/fr-uh6aBZ28.js
  57. 0 0
      static/assets/index-CtyE4Y_n.js
  58. 0 0
      static/assets/it-BTV4EN5M.js
  59. 0 0
      static/assets/it-DS8-WpFb.js
  60. 0 0
      static/assets/ja-C2wYtnNs.js
  61. 0 0
      static/assets/ja-D7msVZp_.js
  62. 0 0
      static/assets/ko-D3jUJSC1.js
  63. 0 0
      static/assets/ko-_Doaq3sb.js
  64. 0 0
      static/assets/locationSensorDefaults-CpTl7DcE.js
  65. 0 0
      static/assets/nl-Bv635sdy.js
  66. 0 0
      static/assets/nl-SJk3IPjL.js
  67. 0 0
      static/assets/pt-BR-BqpXo1eQ.js
  68. 0 0
      static/assets/pt-BR-DInP_RiA.js
  69. 0 0
      static/assets/ru-BWbkfzaI.js
  70. 0 0
      static/assets/ru-Bl9WHwgB.js
  71. 0 0
      static/assets/spoolCost-DROtpkGK.js
  72. 0 0
      static/assets/sv-BybQ0GC3.js
  73. 0 0
      static/assets/sv-DmMBi90T.js
  74. 0 0
      static/assets/tr-CwYXTEB8.js
  75. 0 0
      static/assets/tr-DjyaJxtE.js
  76. 0 0
      static/assets/uk-Ci5lhDIq.js
  77. 0 0
      static/assets/uk-DCVYl2Ma.js
  78. 0 0
      static/assets/zh-CN-7goq5QxD.js
  79. 0 0
      static/assets/zh-CN-BO-CGQ8a.js
  80. 0 0
      static/assets/zh-TW-BSKDtRbu.js
  81. 0 0
      static/assets/zh-TW-Cl8sEy_e.js
  82. 3 3
      static/index.html

+ 1 - 0
CHANGELOG.md

@@ -175,6 +175,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **Dutch (nl) is now a supported interface language (#2891, requested and contributed by @Igiegel)** — Adds `nl` as the fourteenth locale, listed as "Nederlands" in the language picker. The translation was contributed as a file on the issue and needed three corrections before it could be wired up, all of which the parity gate found. First, the nine `stats.timeframe.*` entries had their **keys** translated along with their values (`'today'` had become `'vandaag'`), which would have left the Statistics timeframe selector resolving nothing and rendering raw key names for every Dutch user — the values were kept and the keys restored. Second, the file was translated against an older `en.ts` and was 84 leaves short, missing the Filament Track Switch feed prompts, the AI-detection status strings, the no-3MF internal-history banner, the batch-order stranded-plate notices, the Avery starting-position field and the whole `locationHaSensors` section from #2824; those were translated and added. Rather than splice them in, `nl.ts` was regenerated from the `en.ts` skeleton with the contributor's strings carried over, so its structure, key order and section comments now match the reference locale exactly and a future diff against `en.ts` reads as content rather than as reordering. Third, 229 leaves were identical to English; each was checked individually and all were kept, because Dutch takes most technical UI vocabulary verbatim — printer, filament, status, nozzle, timelapse, dashboard — and Dutch slicer users use the English feature names (support, ironing, prime tower, gap fill) untranslated. Those 123 distinct values are now listed explicitly in a `NL_COGNATES` allow-list in `check-i18n-parity.mjs`, the same shape the other twelve locales use, so the exemption is an enumerated translator decision rather than a blanket skip. Parity green at 6264 leaves across all 14 locales.
 
 ### Changed
+- **A file printed again adds a run to its archive instead of a duplicate** — Every print of a File Manager or project file from the queue archived the file anew, so a two-plate file printed plate by plate showed two archives of the same file, marked as duplicates of each other. The print now joins the archive the file already has, as a run in its Print Log, when it is the same file, printed by the same user, in the same project, and the archive came from the same File Manager file or from none. The card shows the latest run, with that plate's picture, and the Print Log has a **Plate** column once a run printed a plate other than plate 1; older runs get their plate where the queue still knows it. An archive holds one print at a time, so a print of the file that runs while another is queued, printing, or still having its timelapse fetched gets its own archive. Prints started from the slicer or on the printer, uploads, and existing duplicates are unchanged.
 - **Unused API routes and settings removed** — For anyone calling the API from scripts:
   - **Removed routes:** `POST /printers/test` (it took the access code in the URL; adding a printer tests the connection itself), the two debug routes `POST /printers/{id}/debug/simulate-print-complete` and `GET /printers/{id}/runtime-debug`, and `POST /spoolbuddy/devices/{id}/update-status`, which SpoolBuddy stopped calling when updates moved to SSH.
   - **Old virtual printer settings:** `GET /settings/virtual-printer` now returns only `tailscale_disabled`, `archive_name_source` and `status`, and `GET /settings` no longer lists `virtual_printer_enabled`, `virtual_printer_access_code` and `virtual_printer_mode` (`PUT /settings` ignores them). These settings stopped doing anything when each virtual printer got its own settings; use `/virtual-printers` instead.

+ 1 - 0
backend/app/api/routes/print_queue.py

@@ -2944,6 +2944,7 @@ async def stop_queue_item(
                 archive_id=archive.id,
                 queue_item_id=item.id,
                 print_name=archive.print_name,
+                plate_id=item.plate_id if item.plate_id is not None else archive.plate_id,
                 printer_name=await db.scalar(select(Printer.name).where(Printer.id == printer_id)),
                 printer_id=printer_id,
                 started_at=archive.started_at,

+ 45 - 0
backend/app/core/database.py

@@ -5909,6 +5909,13 @@ async def _run_migrations(conn):
             conn, "ALTER TABLE print_archives ADD COLUMN timelapse_baseline_unread BOOLEAN DEFAULT FALSE"
         )
 
+    # Migration: the plate each print log run printed. A library file printed
+    # again now adds its run to the file's archive, whose plate_id is only the
+    # latest run's. Nullable, no default -- identical DDL on SQLite and
+    # Postgres. Backfilled below.
+    await _safe_execute(conn, "ALTER TABLE print_log_entries ADD COLUMN plate_id INTEGER")
+    await _backfill_print_log_plate_ids(conn)
+
     await _migrate_auth_datetimes_to_timestamp(conn)
     await _migrate_add_missing_set_null_fks(conn)
     await _migrate_wallet_transactions_keep_deleted_users(conn)
@@ -5922,6 +5929,44 @@ async def _run_migrations(conn):
     await _migrate_pg_columns_to_model(conn)
 
 
+async def _backfill_print_log_plate_ids(conn) -> None:
+    """Give older print log runs their plate where it is known: the plate of
+    the queue item that ran it, else the archive's when the run is the
+    archive's only one. A run of a reprinted archive without a queue item is
+    left NULL: the archive's plate need not be the one that run printed.
+
+    Once only, gated by a settings flag: runs are written with their plate
+    from now on, and a later dispatch moves the archive's plate to its own
+    run's before that run is logged, which a second pass would read as the
+    old run's."""
+    from sqlalchemy import text
+
+    flag = "_backfill_print_log_plate_ids_done"
+    async with conn.begin_nested():
+        already = (
+            await conn.execute(text('SELECT value FROM settings WHERE "key" = :k'), {"k": flag})
+        ).scalar_one_or_none()
+        if already is not None:
+            return
+        await conn.execute(
+            text(
+                "UPDATE print_log_entries SET plate_id = ("
+                "SELECT q.plate_id FROM print_queue q WHERE q.id = print_log_entries.queue_item_id"
+                ") WHERE plate_id IS NULL AND queue_item_id IS NOT NULL"
+            )
+        )
+        await conn.execute(
+            text(
+                "UPDATE print_log_entries SET plate_id = ("
+                "SELECT a.plate_id FROM print_archives a WHERE a.id = print_log_entries.archive_id"
+                ") WHERE plate_id IS NULL AND queue_item_id IS NULL AND archive_id IS NOT NULL"
+                " AND (SELECT COUNT(*) FROM print_log_entries e"
+                " WHERE e.archive_id = print_log_entries.archive_id) = 1"
+            )
+        )
+        await conn.execute(text('INSERT INTO settings ("key", value) VALUES (:k, :v)'), {"k": flag, "v": "true"})
+
+
 async def _release_slots_of_archived_spools(conn) -> None:
     """An archived spool gives its AMS slots up: archiving does so now, and the
     slots still held by one archived earlier are released here. Idempotent."""

+ 44 - 7
backend/app/main.py

@@ -566,6 +566,35 @@ _timelapse_baselines: dict[int, set[str]] = {}
 # so it is empty without the card being empty (#3412).
 _timelapse_baselines_unread: set[int] = set()
 
+# Archives whose last run still has work after it completed: the timelapse
+# scan and the layer timelapse stitch. Both tell their run from a later one by
+# the archive's start time, so a queue dispatch of the same file must not start
+# a new run on the archive until they end (_claim_reusable_archive in the
+# scheduler). archive_id -> number of such tasks.
+_archive_runs_finishing: dict[int, int] = {}
+
+
+async def _while_run_finishes(archive_id: int, work):
+    """Run ``work`` with the archive marked as still finishing its last run.
+    Marked by the caller's spawn already: see ``_mark_run_finishing``."""
+    try:
+        return await work
+    finally:
+        left = _archive_runs_finishing.get(archive_id, 0) - 1
+        if left > 0:
+            _archive_runs_finishing[archive_id] = left
+        else:
+            _archive_runs_finishing.pop(archive_id, None)
+
+
+def _mark_run_finishing(archive_id: int, work):
+    """``work`` wrapped to keep the archive marked until it ends. Marked now,
+    before the task starts, so no dispatch between the spawn and its first
+    step can take the archive."""
+    _archive_runs_finishing[archive_id] = _archive_runs_finishing.get(archive_id, 0) + 1
+    return _while_run_finishes(archive_id, work)
+
+
 # Track printers waiting for bed to cool after print completion.
 # Event-driven: fires when bed_temper arrives via MQTT below threshold.
 # {printer_id: {"threshold": float, "filename": str, "registered_at": float}}
@@ -6600,7 +6629,10 @@ def _schedule_timelapse_scan(archive_id: int, printer_id: int, *, plate_id: int
     _timelapse_baselines_unread.discard(printer_id)
     baseline = _timelapse_baselines.pop(printer_id, None)
     spawn_background_task(
-        _scan_for_timelapse_with_retries(archive_id, baseline, plate_id=plate_id, baseline_unread=unread),
+        _mark_run_finishing(
+            archive_id,
+            _scan_for_timelapse_with_retries(archive_id, baseline, plate_id=plate_id, baseline_unread=unread),
+        ),
         name=f"scan-timelapse-{archive_id}",
     )
 
@@ -9407,11 +9439,10 @@ async def _close_out_print(printer_id: int, data: dict):
                 )  # SEC-PATH-OK: archive.file_path is DB-stored, internally generated
                 # The plate that printed, as billing reads it: a reprint of
                 # another plate must not log the archive's first plate.
-                _est_grams, _est_cost = _plate_scoped_run_estimate(
-                    archive,
-                    _est_full_path,
-                    billing_plate_id if billing_plate_id is not None else notify_plate_id,
-                )
+                _run_plate_id = billing_plate_id if billing_plate_id is not None else notify_plate_id
+                _est_grams, _est_cost = _plate_scoped_run_estimate(archive, _est_full_path, _run_plate_id)
+                if _run_plate_id is None:
+                    _run_plate_id = archive.plate_id
                 _run_grams = _compute_run_filament_grams(
                     _run_status,
                     _est_grams,
@@ -9443,6 +9474,9 @@ async def _close_out_print(printer_id: int, data: dict):
                     queue_item_id=queue_item_id,
                     status=_run_status,
                     print_name=archive.print_name,
+                    # The plate this run printed, as the estimate above takes
+                    # it; the archive's own is the latest run's.
+                    plate_id=_run_plate_id,
                     printer_name=p_info.name if p_info else None,
                     printer_id=printer_id,
                     started_at=archive.started_at,
@@ -10117,7 +10151,10 @@ async def _close_out_print(printer_id: int, data: dict):
                 except Exception:
                     pass  # Best-effort timelapse session cancellation on error
 
-    spawn_background_task(_background_layer_timelapse(), name="background-layer-timelapse")
+    layer_timelapse = _background_layer_timelapse()
+    if archive_id and print_status == "completed":
+        layer_timelapse = _mark_run_finishing(archive_id, layer_timelapse)
+    spawn_background_task(layer_timelapse, name="background-layer-timelapse")
 
     log_timing("All background tasks scheduled")
 

+ 4 - 0
backend/app/models/print_log.py

@@ -32,6 +32,10 @@ class PrintLogEntry(Base):
         ForeignKey("print_queue.id", ondelete="SET NULL"), nullable=True, index=True
     )
     print_name: Mapped[str | None] = mapped_column(String(255))
+    # Which plate of a multi-plate 3MF this run printed (1-based). The archive
+    # holds one plate, its latest run's, and every run of a file shares that
+    # archive, so a run's own plate is kept here. NULL when not known.
+    plate_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
     printer_name: Mapped[str | None] = mapped_column(String(255))
     printer_id: Mapped[int | None] = mapped_column(Integer)
     status: Mapped[str] = mapped_column(String(20))  # completed, failed, stopped, cancelled, skipped

+ 1 - 0
backend/app/schemas/print_log.py

@@ -17,6 +17,7 @@ class PrintLogEntrySchema(BaseModel):
     id: int
     archive_id: int | None = None
     print_name: str | None = None
+    plate_id: int | None = None
     printer_name: str | None = None
     printer_id: int | None = None
     status: str

+ 11 - 1
backend/app/services/print_log.py

@@ -35,6 +35,7 @@ async def write_log_entry(
     archive_id: int | None = None,
     queue_item_id: int | None = None,
     print_name: str | None = None,
+    plate_id: int | None = None,
     printer_name: str | None = None,
     printer_id: int | None = None,
     started_at: datetime | None = None,
@@ -80,6 +81,7 @@ async def write_log_entry(
         "archive_id": archive_id,
         "queue_item_id": queue_item_id,
         "print_name": print_name,
+        "plate_id": plate_id,
         "printer_name": printer_name,
         "printer_id": printer_id,
         "status": status,
@@ -163,7 +165,15 @@ async def other_runs_of(db: AsyncSession, archive) -> int:
 # completion that finds the queue item already closed reports none. Its
 # outcome (status, reason, grams, cost) is replaced as reported.
 _KEPT_WHEN_NOT_REPORTED = frozenset(
-    {"queue_item_id", "print_name", "printer_name", "thumbnail_path", "created_by_id", "created_by_username"}
+    {
+        "queue_item_id",
+        "print_name",
+        "plate_id",
+        "printer_name",
+        "thumbnail_path",
+        "created_by_id",
+        "created_by_username",
+    }
 )
 
 

+ 118 - 16
backend/app/services/print_scheduler.py

@@ -1378,6 +1378,12 @@ class PrintScheduler:
         # event-loop thread, so this dict needs no lock.
         # item_id -> (task, printer_id)
         self._inflight: dict[int, tuple[asyncio.Task, int | None]] = {}
+        # Archives a library dispatch still in flight is adding its run to:
+        # archive_id -> item_id. Uploads run in parallel, and an archive holds
+        # one active run, so a second dispatch of the same file must not take
+        # it before the first one's print shows as running. Released by
+        # `_dispatch_one` when the dispatch ends; same threading as above.
+        self._archive_claims: dict[int, int] = {}
         # Expected prints registered by `_start_print` that have not yet had a
         # print command sent. Populated at registration, dropped once
         # `start_print()` succeeds, and rolled back by `_dispatch_one` on every
@@ -2847,6 +2853,9 @@ class PrintScheduler:
                 # Likewise the print user it set: the printer's next print is
                 # not this item's.
                 self._rollback_unconfirmed_print_user(item_id)
+                # A sent print now shows as running on its archive; an unsent
+                # one leaves it free again.
+                self._release_archive_claims(item_id)
                 # Mirror the pre-#1625 background-dispatch lifecycle: a
                 # reservation survives only after start_print() accepted the
                 # command. Failure, cancellation, deferral, and exceptions all
@@ -8054,6 +8063,84 @@ class PrintScheduler:
 
         return prev_item.status in ("completed", "cancelled")
 
+    def _release_archive_claims(self, item_id: int) -> None:
+        for archive_id in [a for a, owner in self._archive_claims.items() if owner == item_id]:
+            del self._archive_claims[archive_id]
+
+    async def _claim_reusable_archive(
+        self, db: AsyncSession, item: PrintQueueItem, file_path: Path
+    ) -> PrintArchive | None:
+        """The archive a library dispatch adds its run to, claimed for it, or
+        None for a new one.
+
+        The same file of the same owner, in the same project, archived from
+        this library file or from none (one since deleted, or a print started
+        elsewhere), with its copy of the file still on disk. Only an idle
+        archive: it holds one active run, so while one of its prints is
+        queued, being sent, running, or still finishing (its timelapse),
+        another print of the file gets an archive of its own, as before.
+        """
+        from backend.app.main import _active_prints, _archive_runs_finishing
+        from backend.app.services.archive import ArchiveService
+
+        # Off the file as it is now: the stored hash is not refreshed when the
+        # file changes on disk (an external folder, overwritten in place).
+        content_hash = await asyncio.to_thread(ArchiveService.compute_file_hash, file_path)
+        owner = (
+            PrintArchive.created_by_id.is_(None)
+            if item.created_by_id is None
+            else PrintArchive.created_by_id == item.created_by_id
+        )
+        project = (
+            PrintArchive.project_id.is_(None) if item.project_id is None else PrintArchive.project_id == item.project_id
+        )
+        candidates = (
+            await db.scalars(
+                select(PrintArchive)
+                .where(
+                    PrintArchive.content_hash == content_hash,
+                    owner,
+                    project,
+                    or_(PrintArchive.library_file_id == item.library_file_id, PrintArchive.library_file_id.is_(None)),
+                    PrintArchive.deleted_at.is_(None),
+                    PrintArchive.status != "printing",
+                )
+                .order_by(PrintArchive.id.desc())
+            )
+        ).all()
+        if not candidates:
+            return None
+        # A dispatch sets the item's archive while the item is still pending
+        # and keeps it through printing, so this also covers a print being
+        # sent or waiting to start. (The expected-print table can't say that:
+        # a print start leaves one of its names there for two hours.)
+        queued = set(
+            (
+                await db.scalars(
+                    select(PrintQueueItem.archive_id).where(
+                        PrintQueueItem.archive_id.in_([c.id for c in candidates]),
+                        PrintQueueItem.status.in_(("pending", "printing")),
+                        PrintQueueItem.id != item.id,
+                    )
+                )
+            ).all()
+        )
+        for candidate in candidates:
+            if not candidate.file_path or not (settings.base_dir / candidate.file_path).is_file():
+                continue
+            # No await from here to the claim: two parallel dispatches of the
+            # file cannot both take it.
+            if (
+                candidate.id in queued
+                or candidate.id in self._archive_claims
+                or candidate.id in _active_prints.values()
+                or candidate.id in _archive_runs_finishing
+            ):
+                continue
+            self._archive_claims[candidate.id] = item.id
+            return candidate
+        return None
+
     async def _repoint_siblings_at_archive(
         self,
         db: AsyncSession,
@@ -8943,10 +9030,9 @@ class PrintScheduler:
 
             # Persist the queue item's selected plate onto the archive so Print
             # History can show the actual plate after cancel/fail/complete (#2603).
-            # Only when the archive doesn't already carry one, so a reprint of a
-            # plate-specific archive isn't relabelled by a differently-plated
-            # queue row.
-            if archive.plate_id is None and item.plate_id is not None:
+            # The archive shows its latest run's plate; each run keeps its own
+            # on its print log entry.
+            if item.plate_id is not None:
                 archive.plate_id = item.plate_id
 
             # Ask-for-outcome opt-in rides from the queue item to the archive
@@ -9001,20 +9087,35 @@ class PrintScheduler:
             # cleared field -- so every consumed print logged "from library
             # file None", which is the one case worth being able to trace.
             source_library_file_id = item.library_file_id
+            reused_archive = False
             try:
                 from backend.app.services.archive import ArchiveService
 
-                archive_service = ArchiveService(db)
-                archive = await archive_service.archive_print(
-                    printer_id=item.printer_id,
-                    source_file=file_path,
-                    original_filename=filename,
-                    created_by_id=item.created_by_id,
-                    project_id=item.project_id,
-                    cost_center_id=item.cost_center_id,
-                    library_file_id=item.library_file_id,  # per-file project progress (#1897)
-                    plate_id=item.plate_id,  # selected plate → Print History (#2603)
-                )
+                # The file printed before: this run joins its archive rather
+                # than archiving the same file a second time.
+                archive = await self._claim_reusable_archive(db, item, file_path)
+                if archive is not None:
+                    reused_archive = True
+                    if archive.library_file_id is None:
+                        archive.library_file_id = item.library_file_id
+                    # This run's, as a new archive would have them: the
+                    # archive shows its latest run's plate, and billing falls
+                    # back to the archive's cost centre.
+                    if item.plate_id is not None:
+                        archive.plate_id = item.plate_id
+                    archive.cost_center_id = item.cost_center_id
+                else:
+                    archive_service = ArchiveService(db)
+                    archive = await archive_service.archive_print(
+                        printer_id=item.printer_id,
+                        source_file=file_path,
+                        original_filename=filename,
+                        created_by_id=item.created_by_id,
+                        project_id=item.project_id,
+                        cost_center_id=item.cost_center_id,
+                        library_file_id=item.library_file_id,  # per-file project progress (#1897)
+                        plate_id=item.plate_id,  # selected plate → Print History (#2603)
+                    )
                 if archive:
                     item.archive_id = archive.id
                     if item.confirm_outcome:
@@ -9056,8 +9157,9 @@ class PrintScheduler:
                     # for sensor history + concurrent cancels (#1853).
                     await db.commit()
                     logger.info(
-                        "Queue item %s: Created archive %s from library file %s",
+                        "Queue item %s: %s archive %s from library file %s",
                         item.id,
+                        "Reused" if reused_archive else "Created",
                         archive.id,
                         source_library_file_id,
                     )

+ 16 - 0
backend/tests/integration/test_print_log_one_entry_per_run.py

@@ -133,3 +133,19 @@ class TestOtherRunsOf:
         await db_session.commit()
 
         assert await other_runs_of(db_session, rows["archive"]) == 1
+
+
+async def test_the_runs_plate_stays_when_the_real_end_does_not_name_it(db_session, rows):
+    """An offline stop logged the queue item's plate; the end the printer
+    reported after all comes without one."""
+    await write_log_entry(
+        db_session,
+        status="cancelled",
+        archive_id=rows["archive"].id,
+        printer_id=rows["printer"],
+        started_at=START,
+        plate_id=2,
+    )
+    await _write(db_session, rows, status="completed")
+
+    assert list(await db_session.scalars(select(PrintLogEntry.plate_id))) == [2]

+ 32 - 0
backend/tests/integration/test_print_queue_api.py

@@ -4073,6 +4073,38 @@ async def test_stop_offline_reconciles_linked_archive_status_2603(
     assert archive.failure_reason == "userCancelled"
 
 
+@pytest.mark.asyncio
+async def test_stop_offline_logs_the_run_with_the_items_plate(
+    async_client: AsyncClient, printer_factory, archive_factory, db_session
+):
+    """The run's own plate is the queue item's: the archive's is only its
+    latest run's, and a reprint of another plate may not have changed it."""
+    from unittest.mock import MagicMock, patch
+
+    from sqlalchemy import select
+
+    from backend.app.models.print_log import PrintLogEntry
+    from backend.app.models.print_queue import PrintQueueItem
+
+    printer = await printer_factory(name="Offline printer")
+    archive = await archive_factory(printer.id, status="printing", plate_id=1, with_run=False)
+    item = PrintQueueItem(printer_id=printer.id, archive_id=archive.id, status="printing", plate_id=3)
+    db_session.add(item)
+    await db_session.commit()
+
+    with patch(
+        "backend.app.services.printer_manager.printer_manager.stop_print",
+        MagicMock(return_value=False),
+    ):
+        resp = await async_client.post(f"/api/v1/queue/{item.id}/stop")
+
+    assert resp.status_code == 200
+    plates = list(
+        await db_session.scalars(select(PrintLogEntry.plate_id).where(PrintLogEntry.archive_id == archive.id))
+    )
+    assert plates == [3]
+
+
 @pytest.mark.asyncio
 async def test_stop_online_leaves_archive_for_mqtt_to_reconcile_2603(
     async_client: AsyncClient, printer_factory, archive_factory, db_session

+ 86 - 0
backend/tests/unit/test_archive_run_finishing.py

@@ -0,0 +1,86 @@
+"""An archive stays marked while its last run's timelapse work goes on.
+
+The timelapse scan and the layer timelapse stitch run after the print
+completed and tell their run from a later one by the archive's start time. A
+queue dispatch of the same file adds its run to the archive only once that
+work is over (``_claim_reusable_archive``), or the work would stop short and
+the video never be attached.
+"""
+
+import asyncio
+from unittest.mock import patch
+
+import pytest
+
+from backend.app import main as main_module
+from backend.tests.unit.test_run_estimate_plate import _complete
+
+
+@pytest.fixture(autouse=True)
+def _clean_state():
+    main_module._archive_runs_finishing.clear()
+    main_module._active_prints.clear()
+    main_module._print_plate_ids.clear()
+    yield
+    main_module._archive_runs_finishing.clear()
+    main_module._active_prints.clear()
+    main_module._print_plate_ids.clear()
+
+
+@pytest.mark.asyncio
+async def test_marked_from_the_spawn_until_the_work_ends():
+    release = asyncio.Event()
+
+    async def work():
+        await release.wait()
+
+    wrapped = main_module._mark_run_finishing(7, work())
+    assert main_module._archive_runs_finishing == {7: 1}
+    second = main_module._mark_run_finishing(7, asyncio.sleep(0))
+    await second
+    assert main_module._archive_runs_finishing == {7: 1}, "the other task is still running"
+
+    task = asyncio.create_task(wrapped)
+    release.set()
+    await task
+    assert main_module._archive_runs_finishing == {}
+
+
+@pytest.mark.asyncio
+async def test_a_failing_task_unmarks_too():
+    async def work():
+        raise RuntimeError("scan failed")
+
+    with pytest.raises(RuntimeError):
+        await main_module._mark_run_finishing(7, work())
+    assert main_module._archive_runs_finishing == {}
+
+
+def test_the_timelapse_scan_marks_its_archive():
+    spawned = []
+    with patch("backend.app.main.spawn_background_task", side_effect=lambda coro, name: spawned.append(coro)):
+        main_module._schedule_timelapse_scan(12, 3, plate_id=None)
+    assert main_module._archive_runs_finishing == {12: 1}
+    for coro in spawned:
+        coro.close()
+
+
+@pytest.mark.asyncio
+async def test_the_layer_timelapse_of_a_completed_print_marks_its_archive(
+    test_engine, db_session, printer_factory, archive_factory
+):
+    printer = await printer_factory()
+    archive = await archive_factory(printer.id, filename="multi.gcode.3mf", status="printing", with_run=False)
+    main_module._active_prints[(printer.id, "multi.gcode.3mf")] = archive.id
+    spawned: list[str] = []
+    real_spawn = main_module.spawn_background_task
+
+    def spawn(coro, name=None, **kwargs):
+        if name == "background-layer-timelapse":
+            spawned.append(main_module._archive_runs_finishing.get(archive.id, 0))
+        return real_spawn(coro, name=name, **kwargs)
+
+    with patch("backend.app.main.spawn_background_task", side_effect=spawn):
+        await _complete(test_engine, printer.id)
+
+    assert spawned == [1]

+ 1 - 0
backend/tests/unit/test_forget_printer.py

@@ -26,6 +26,7 @@ NOT_FORGOTTEN = {
     "_print_cost_center_ids": "keyed by archive (dropped with the printer's expected/active prints)",
     "_print_plate_ids": "keyed by archive (dropped with the printer's expected/active prints)",
     "_owned_print_archive_ids": "keyed by archive (dropped with the printer's expected/active prints)",
+    "_archive_runs_finishing": "keyed by archive, cleared by the task it counts when that task ends",
     "_ams_assignment_locks": "a lock carries nothing over; popping one a task holds would let a second be made",
     "_fallback_recovery_locks": "a lock carries nothing over (see its comment on popping)",
     "_HMS_FAILURE_REASONS": "constant",

+ 334 - 0
backend/tests/unit/test_library_dispatch_reuses_archive.py

@@ -0,0 +1,334 @@
+"""A library file printed again adds its run to the file's archive.
+
+Every queue dispatch of a library or project file used to archive the file
+anew, so a two-plate file printed plate by plate showed two archives of the
+same file, flagged as duplicates of each other. The dispatch now adds its run
+to the archive the file already has: same file, same owner, same project, from
+this library file (or one since deleted).
+
+An archive holds one active run, and uploads run in parallel, so it is only
+taken while idle: not printing, not sent or waiting to start, not queued, not
+claimed by another dispatch still uploading. A print of the file running at
+the same time gets an archive of its own, as before.
+"""
+
+import hashlib
+from contextlib import ExitStack
+from datetime import datetime
+from pathlib import Path
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
+
+import backend.app.models  # noqa: F401 - populate Base.metadata
+import backend.app.services.print_scheduler as scheduler_module
+from backend.app import main as main_module
+from backend.app.core.database import Base
+from backend.app.models.archive import PrintArchive
+from backend.app.models.library import LibraryFile
+from backend.app.models.print_queue import PrintQueueItem
+from backend.app.models.printer import Printer
+from backend.app.services.print_scheduler import PrintScheduler
+from backend.tests._fixtures.background_tasks import discarding_spawn_patch
+
+CONTENT = b"two plates"
+HASH = hashlib.sha256(CONTENT).hexdigest()
+
+
+@pytest.fixture(autouse=True)
+def _clean_print_state():
+    main_module._active_prints.clear()
+    main_module._expected_prints.clear()
+    main_module._archive_runs_finishing.clear()
+    yield
+    main_module._active_prints.clear()
+    main_module._expected_prints.clear()
+    main_module._archive_runs_finishing.clear()
+
+
+@pytest.fixture
+async def case(tmp_path):
+    engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
+    async with engine.begin() as conn:
+        await conn.run_sync(Base.metadata.create_all)
+    session_maker = async_sessionmaker(engine, expire_on_commit=False)
+
+    source = tmp_path / "library" / "planter.3mf"
+    source.parent.mkdir()
+    source.write_bytes(CONTENT)
+    async with session_maker() as db:
+        printer = Printer(name="X1C", serial_number="SERIAL-1", ip_address="127.0.0.1", access_code="code", model="X1C")
+        library_file = LibraryFile(
+            filename="planter.3mf",
+            file_path=str(source),
+            file_type="3mf",
+            file_size=source.stat().st_size,
+            file_hash=HASH,
+        )
+        db.add_all([printer, library_file])
+        await db.commit()
+        ctx = SimpleNamespace(
+            session_maker=session_maker,
+            base_dir=tmp_path,
+            printer_id=printer.id,
+            library_file_id=library_file.id,
+        )
+
+    async def archive(**overrides) -> int:
+        """An archive of the file, its copy on disk unless ``on_disk=False``."""
+        on_disk = overrides.pop("on_disk", True)
+        rel = Path("archive") / f"copy-{overrides.get('plate_id', 1)}-{len(overrides)}.3mf"
+        async with session_maker() as db:
+            fields = {
+                "printer_id": ctx.printer_id,
+                "filename": "planter.3mf",
+                "file_path": str(rel),
+                "file_size": 10,
+                "content_hash": HASH,
+                "status": "completed",
+                "library_file_id": ctx.library_file_id,
+                "plate_id": 1,
+            }
+            row = PrintArchive(**(fields | overrides))
+            db.add(row)
+            await db.commit()
+            if on_disk:
+                path = tmp_path / rel
+                path.parent.mkdir(parents=True, exist_ok=True)
+                path.write_bytes(CONTENT)
+            return row.id
+
+    async def item(**overrides) -> int:
+        async with session_maker() as db:
+            fields = {
+                "printer_id": ctx.printer_id,
+                "library_file_id": ctx.library_file_id,
+                "status": "pending",
+                "plate_id": 2,
+            }
+            row = PrintQueueItem(**(fields | overrides))
+            db.add(row)
+            await db.commit()
+            return row.id
+
+    ctx.archive = archive
+    ctx.item = item
+    with patch.object(scheduler_module.settings, "base_dir", tmp_path):
+        yield ctx
+    await engine.dispose()
+
+
+async def _claim(ctx, scheduler, item_id):
+    async with ctx.session_maker() as db:
+        item = await db.get(PrintQueueItem, item_id)
+        library_file = await db.get(LibraryFile, ctx.library_file_id)
+        archive = await scheduler._claim_reusable_archive(db, item, Path(library_file.file_path))
+        return archive.id if archive is not None else None
+
+
+@pytest.mark.asyncio
+async def test_the_files_archive_is_reused(case):
+    existing = await case.archive()
+    assert await _claim(case, PrintScheduler(), await case.item()) == existing
+
+
+@pytest.mark.asyncio
+async def test_the_file_is_matched_as_it_is_on_disk(case):
+    """The stored hash is not refreshed when the file changes in place (an
+    external folder): a stale one neither finds the old content's archive
+    nor misses the current one."""
+    existing = await case.archive()
+    async with case.session_maker() as db:
+        (await db.get(LibraryFile, case.library_file_id)).file_hash = "b" * 64
+        await db.commit()
+    assert await _claim(case, PrintScheduler(), await case.item()) == existing
+
+
+@pytest.mark.asyncio
+async def test_an_archive_whose_library_file_was_deleted_is_reused(case):
+    existing = await case.archive(library_file_id=None)
+    assert await _claim(case, PrintScheduler(), await case.item()) == existing
+
+
+@pytest.mark.asyncio
+async def test_the_newest_idle_archive_is_taken(case):
+    await case.archive()
+    newest = await case.archive(tags="newest")
+    assert await _claim(case, PrintScheduler(), await case.item()) == newest
+
+
+@pytest.mark.parametrize(
+    "archive_overrides, item_overrides",
+    [
+        pytest.param({"content_hash": "b" * 64}, {}, id="another-file"),
+        pytest.param({"created_by_id": 7}, {}, id="another-owner"),
+        pytest.param({}, {"created_by_id": 7}, id="owned-print-of-an-unowned-archive"),
+        pytest.param({"project_id": 3}, {}, id="another-project"),
+        pytest.param({}, {"project_id": 3}, id="project-print-of-a-loose-archive"),
+        pytest.param({"library_file_id": 999}, {}, id="another-library-file"),
+        pytest.param({"deleted_at": datetime(2026, 10, 1)}, {}, id="deleted"),
+        pytest.param({"status": "printing"}, {}, id="printing"),
+        pytest.param({"on_disk": False}, {}, id="its-copy-is-gone"),
+    ],
+)
+@pytest.mark.asyncio
+async def test_no_archive_that_is_not_this_files_or_not_usable(case, archive_overrides, item_overrides):
+    await case.archive(**archive_overrides)
+    assert await _claim(case, PrintScheduler(), await case.item(**item_overrides)) is None
+
+
+@pytest.mark.asyncio
+async def test_not_while_another_queue_item_prints_or_waits_on_it(case):
+    existing = await case.archive()
+    await case.item(archive_id=existing, library_file_id=None, status="pending")
+    assert await _claim(case, PrintScheduler(), await case.item()) is None
+
+
+@pytest.mark.asyncio
+async def test_not_while_one_of_its_prints_runs_or_finishes(case):
+    existing = await case.archive()
+    main_module._active_prints[(case.printer_id, "planter.3mf")] = existing
+    assert await _claim(case, PrintScheduler(), await case.item()) is None
+
+    # Completed, but its timelapse is still being looked for: that scan tells
+    # its run by the archive's start time, which a new run would change.
+    main_module._active_prints.clear()
+    main_module._archive_runs_finishing[existing] = 1
+    assert await _claim(case, PrintScheduler(), await case.item()) is None
+
+
+@pytest.mark.asyncio
+async def test_a_name_left_from_an_earlier_dispatch_does_not_block_it(case):
+    """A print start leaves the '<name>.gcode' variant of its registration
+    behind for two hours; the plate printed next must still join."""
+    existing = await case.archive()
+    main_module._expected_prints[(case.printer_id, "planter.gcode")] = existing
+    assert await _claim(case, PrintScheduler(), await case.item()) == existing
+
+
+@pytest.mark.asyncio
+async def test_two_dispatches_in_flight_never_share_it(case):
+    existing = await case.archive()
+    scheduler = PrintScheduler()
+    first, second = await case.item(), await case.item()
+
+    assert await _claim(case, scheduler, first) == existing
+    assert await _claim(case, scheduler, second) is None
+
+    # The first dispatch ended: the archive is free for the next print.
+    scheduler._release_archive_claims(first)
+    assert await _claim(case, scheduler, second) == existing
+
+
+async def _start(case, item_id, archive_print):
+    scheduler = PrintScheduler()
+    patches = [
+        patch("backend.app.services.archive.ArchiveService.archive_print", new=archive_print),
+        patch("backend.app.services.print_scheduler.printer_manager.is_connected", MagicMock(return_value=True)),
+        patch("backend.app.services.print_scheduler.printer_manager.get_status", MagicMock(return_value=None)),
+        patch("backend.app.services.print_scheduler.printer_manager.start_print", MagicMock(return_value=True)),
+        patch("backend.app.services.print_scheduler.printer_manager.set_awaiting_plate_clear", MagicMock()),
+        patch(
+            "backend.app.services.print_scheduler.get_ftp_retry_settings", AsyncMock(return_value=(False, 0, 0, 1.0))
+        ),
+        patch("backend.app.services.print_scheduler.delete_file_async", AsyncMock(return_value=True)),
+        patch("backend.app.services.print_scheduler.upload_file_async", AsyncMock(return_value=True)),
+        patch("backend.app.services.print_scheduler.cache_3mf_download", MagicMock()),
+        discarding_spawn_patch(),
+        patch("backend.app.services.notification_service.notification_service.on_queue_job_started", AsyncMock()),
+        patch("backend.app.services.notification_service.notification_service.on_queue_job_failed", AsyncMock()),
+        patch("backend.app.services.mqtt_relay.mqtt_relay.on_queue_job_started", AsyncMock()),
+        patch.object(scheduler, "_propagate_owner_to_printer_manager", AsyncMock()),
+        patch.object(scheduler, "_power_off_if_needed", AsyncMock()),
+    ]
+    with ExitStack() as stack:
+        for patcher in patches:
+            stack.enter_context(patcher)
+        async with case.session_maker() as db:
+            item = await db.get(PrintQueueItem, item_id)
+            await scheduler._start_print(db, item)
+    async with case.session_maker() as db:
+        item = await db.get(PrintQueueItem, item_id)
+        return item, (await db.get(PrintArchive, item.archive_id) if item.archive_id else None)
+
+
+@pytest.mark.asyncio
+async def test_the_dispatch_adds_its_run_to_the_files_archive(case):
+    existing = await case.archive(cost_center_id=5)
+    archive_print = AsyncMock()
+
+    item, archive = await _start(case, await case.item(plate_id=2), archive_print)
+
+    archive_print.assert_not_called()
+    assert item.archive_id == existing
+    # The latest run's, as a new archive would have them.
+    assert archive.plate_id == 2
+    assert archive.cost_center_id is None
+
+
+@pytest.mark.asyncio
+async def test_a_file_never_archived_is_archived(case):
+    created = {}
+
+    async def archive_print(self, **kwargs):
+        row = PrintArchive(
+            printer_id=kwargs["printer_id"],
+            filename="planter.3mf",
+            file_path="archive/new.3mf",
+            file_size=10,
+            content_hash=HASH,
+            status="archived",
+        )
+        self.db.add(row)
+        await self.db.flush()
+        created["id"] = row.id
+        return row
+
+    item, _archive = await _start(case, await case.item(), archive_print)
+
+    assert item.archive_id == created["id"]
+
+
+@pytest.mark.asyncio
+async def test_a_reprint_of_another_plate_shows_that_plate(case):
+    """The archive shows its latest run's plate; the first one's stays on its
+    print log entry."""
+    existing = await case.archive(plate_id=1)
+
+    _item, archive = await _start(
+        case, await case.item(archive_id=existing, library_file_id=None, plate_id=3), AsyncMock()
+    )
+
+    assert archive.plate_id == 3
+
+
+@pytest.mark.parametrize("fails", [False, True], ids=["sent", "failed"])
+@pytest.mark.asyncio
+async def test_the_dispatch_gives_its_claim_back_when_it_ends(fails):
+    """Sent, its print shows as running; failed, the archive is free again.
+    Another dispatch's claim is left alone."""
+    scheduler = PrintScheduler()
+
+    async def start_print(db, item):
+        scheduler._archive_claims[298] = 597
+        if fails:
+            raise RuntimeError("upload failed")
+
+    scheduler._archive_claims[300] = 600
+    db = MagicMock()
+    db.get = AsyncMock(return_value=MagicMock(id=597))
+    session = MagicMock()
+    session.__aenter__ = AsyncMock(return_value=db)
+    session.__aexit__ = AsyncMock(return_value=False)
+    with ExitStack() as stack:
+        stack.enter_context(patch("backend.app.services.print_scheduler.async_session", return_value=session))
+        stack.enter_context(patch.object(scheduler, "_claim_for_dispatch", AsyncMock(return_value=True)))
+        stack.enter_context(patch.object(scheduler, "_start_print", side_effect=start_print))
+        stack.enter_context(patch.object(scheduler, "_clear_dispatch_claim", AsyncMock()))
+        if fails:
+            stack.enter_context(pytest.raises(RuntimeError))
+        await scheduler._dispatch_one(597)
+
+    assert scheduler._archive_claims == {300: 600}

+ 116 - 0
backend/tests/unit/test_print_log_plate_backfill.py

@@ -0,0 +1,116 @@
+"""Older print log runs get the plate they printed where it is known.
+
+Runs are now written with their plate. For runs written before, it is the
+plate of the queue item that ran them, or the archive's when the run is that
+archive's only one. A run of a reprinted archive with no queue item stays
+NULL: the archive's plate need not be the one that run printed.
+"""
+
+import pytest
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
+
+import backend.app.models  # noqa: F401 - populate Base.metadata
+from backend.app.core.database import Base, _backfill_print_log_plate_ids
+from backend.app.models.archive import PrintArchive
+from backend.app.models.print_log import PrintLogEntry
+from backend.app.models.print_queue import PrintQueueItem
+from backend.app.models.printer import Printer
+
+
+@pytest.mark.asyncio
+async def test_older_runs_get_their_plate_where_it_is_known():
+    engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
+    try:
+        async with engine.begin() as conn:
+            await conn.run_sync(Base.metadata.create_all)
+        maker = async_sessionmaker(engine, expire_on_commit=False)
+        async with maker() as db:
+            printer = Printer(name="P", serial_number="S", ip_address="127.0.0.1", access_code="c", model="X1C")
+            single = PrintArchive(filename="a.3mf", file_path="a", file_size=1, status="completed", plate_id=2)
+            reprinted = PrintArchive(filename="b.3mf", file_path="b", file_size=1, status="completed", plate_id=5)
+            db.add_all([printer, single, reprinted])
+            await db.flush()
+            queued = PrintQueueItem(printer_id=printer.id, archive_id=reprinted.id, status="completed", plate_id=4)
+            db.add(queued)
+            await db.flush()
+            runs = {
+                "queued": PrintLogEntry(archive_id=reprinted.id, queue_item_id=queued.id, status="completed"),
+                "reprinted": PrintLogEntry(archive_id=reprinted.id, status="completed"),
+                "single": PrintLogEntry(archive_id=single.id, status="completed"),
+                "known": PrintLogEntry(archive_id=single.id, status="failed", plate_id=1, queue_item_id=queued.id),
+                "orphan": PrintLogEntry(archive_id=None, status="completed"),
+            }
+            db.add_all(runs.values())
+            await db.commit()
+            ids = {name: run.id for name, run in runs.items()}
+
+        for _ in range(2):  # every start runs it; the second changes nothing
+            async with engine.begin() as conn:
+                await _backfill_print_log_plate_ids(conn)
+
+        async with maker() as db:
+            plates = dict((await db.execute(select(PrintLogEntry.id, PrintLogEntry.plate_id))).tuples().all())
+        assert {name: plates[run_id] for name, run_id in ids.items()} == {
+            "queued": 4,
+            "reprinted": None,
+            # Two runs on the archive now, one of them already known: the
+            # archive's plate is not taken for the other.
+            "single": None,
+            "known": 1,
+            "orphan": None,
+        }
+    finally:
+        await engine.dispose()
+
+
+@pytest.mark.asyncio
+async def test_the_only_run_of_an_archive_gets_the_archives_plate():
+    engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
+    try:
+        async with engine.begin() as conn:
+            await conn.run_sync(Base.metadata.create_all)
+        maker = async_sessionmaker(engine, expire_on_commit=False)
+        async with maker() as db:
+            archive = PrintArchive(filename="a.3mf", file_path="a", file_size=1, status="completed", plate_id=2)
+            db.add(archive)
+            await db.flush()
+            run = PrintLogEntry(archive_id=archive.id, status="completed")
+            db.add(run)
+            await db.commit()
+
+        async with engine.begin() as conn:
+            await _backfill_print_log_plate_ids(conn)
+
+        async with maker() as db:
+            assert await db.scalar(select(PrintLogEntry.plate_id).where(PrintLogEntry.id == run.id)) == 2
+    finally:
+        await engine.dispose()
+
+
+@pytest.mark.asyncio
+async def test_it_runs_once():
+    """A later dispatch moves the archive's plate to its own run's before
+    that run is logged; a second pass would give it to the old run."""
+    engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
+    try:
+        async with engine.begin() as conn:
+            await conn.run_sync(Base.metadata.create_all)
+        async with engine.begin() as conn:
+            await _backfill_print_log_plate_ids(conn)  # the first start: nothing to do
+        maker = async_sessionmaker(engine, expire_on_commit=False)
+        async with maker() as db:
+            archive = PrintArchive(filename="a.3mf", file_path="a", file_size=1, status="printing", plate_id=2)
+            db.add(archive)
+            await db.flush()
+            run = PrintLogEntry(archive_id=archive.id, status="completed")
+            db.add(run)
+            await db.commit()
+
+        async with engine.begin() as conn:
+            await _backfill_print_log_plate_ids(conn)
+
+        async with maker() as db:
+            assert await db.scalar(select(PrintLogEntry.plate_id).where(PrintLogEntry.id == run.id)) is None
+    finally:
+        await engine.dispose()

+ 63 - 7
backend/tests/unit/test_run_estimate_plate.py

@@ -1,17 +1,19 @@
-"""The print-log estimate covers the plate that printed.
+"""The print-log estimate covers the plate that printed, and the run keeps it.
 
-The archive keeps the plate of its first print. A later run of another plate
-of the same multi-plate file, dispatched directly or from the queue, has to be
-estimated for that plate, not for the archive's.
+The archive holds one plate. A later run of another plate of the same
+multi-plate file, dispatched directly or from the queue, has to be estimated
+for that plate, not for the archive's, and its print log entry names it.
 """
 
 import asyncio
 from unittest.mock import AsyncMock, MagicMock, patch
 
 import pytest
+from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
 
 from backend.app import main as main_module
+from backend.app.models.print_log import PrintLogEntry
 from backend.app.models.print_queue import PrintQueueItem
 from backend.app.services.bambu_ftp import DeleteResult
 
@@ -25,9 +27,11 @@ def _clean_state():
     main_module._print_plate_ids.clear()
 
 
-async def _complete(test_engine, printer_id: int) -> list:
+async def _complete(test_engine, printer_id: int, logged: list | None = None) -> list:
     """Run on_print_complete against the test database; return the plates the
-    estimates were asked for (billing first, then the print log)."""
+    estimates were asked for (billing first, then the print log). ``logged``
+    gets the plates of the print log entries written, read before the
+    background work is torn down."""
     real_db = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
     asked: list = []
 
@@ -54,7 +58,9 @@ async def _complete(test_engine, printer_id: int) -> list:
         mock_relay.on_queue_job_completed = AsyncMock()
         mock_notif.on_print_complete = AsyncMock()
         mock_plug.on_print_complete = AsyncMock()
-        mock_pm.get_printer = MagicMock(return_value=MagicMock(name="Test", serial_number="TEST123"))
+        printer_info = MagicMock(serial_number="TEST123")
+        printer_info.name = "Test"  # name= would only name the mock
+        mock_pm.get_printer = MagicMock(return_value=printer_info)
         mock_pm.get_current_print_user = MagicMock(return_value=None)
         mock_pm.clear_current_print_user = MagicMock()
         mock_pm.set_awaiting_plate_clear = MagicMock()
@@ -63,6 +69,9 @@ async def _complete(test_engine, printer_id: int) -> list:
             printer_id,
             {"status": "completed", "filename": "multi.gcode.3mf", "subtask_name": "multi", "progress": 100},
         )
+        if logged is not None:
+            async with real_db() as db:
+                logged.extend(await db.scalars(select(PrintLogEntry.plate_id)))
 
         for task in asyncio.all_tasks() - tasks_before:
             task.cancel()
@@ -99,3 +108,50 @@ async def test_a_queue_run_is_estimated_for_the_queue_items_plate(
     main_module._active_prints[(printer.id, "multi.gcode.3mf")] = archive.id
 
     assert await _complete(test_engine, printer.id) == [2, 2]
+
+
+@pytest.mark.asyncio
+async def test_a_direct_print_logs_its_plate(test_engine, db_session, printer_factory, archive_factory):
+    printer = await printer_factory()
+    archive = await archive_factory(
+        printer.id, filename="multi.gcode.3mf", plate_id=1, status="printing", with_run=False
+    )
+    main_module._active_prints[(printer.id, "multi.gcode.3mf")] = archive.id
+    main_module._print_plate_ids[archive.id] = 3
+
+    logged: list = []
+    await _complete(test_engine, printer.id, logged)
+
+    assert logged == [3]
+
+
+@pytest.mark.asyncio
+async def test_a_queue_run_logs_the_queue_items_plate(test_engine, db_session, printer_factory, archive_factory):
+    printer = await printer_factory()
+    archive = await archive_factory(
+        printer.id, filename="multi.gcode.3mf", plate_id=1, status="printing", with_run=False
+    )
+    db_session.add(PrintQueueItem(printer_id=printer.id, archive_id=archive.id, plate_id=2, status="printing"))
+    await db_session.commit()
+    main_module._active_prints[(printer.id, "multi.gcode.3mf")] = archive.id
+
+    logged: list = []
+    await _complete(test_engine, printer.id, logged)
+
+    assert logged == [2]
+
+
+@pytest.mark.asyncio
+async def test_a_print_with_no_known_plate_logs_the_archives(test_engine, db_session, printer_factory, archive_factory):
+    """Started on the printer: the archive was made for this print, its plate
+    read off the printed file's name."""
+    printer = await printer_factory()
+    archive = await archive_factory(
+        printer.id, filename="multi.gcode.3mf", plate_id=4, status="printing", with_run=False
+    )
+    main_module._active_prints[(printer.id, "multi.gcode.3mf")] = archive.id
+
+    logged: list = []
+    await _complete(test_engine, printer.id, logged)
+
+    assert logged == [4]

+ 31 - 0
frontend/src/__tests__/components/PrintLogModal.test.tsx

@@ -114,6 +114,37 @@ describe('PrintLogModal', () => {
     expect(screen.queryByText('filamentRunout')).not.toBeInTheDocument();
   });
 
+  it('names the plate of each run once another plate printed', async () => {
+    vi.mocked(api.getArchiveRuns).mockResolvedValue({
+      total: 2,
+      items: [
+        { ...sampleRuns.items[0], plate_id: 2 },
+        { ...sampleRuns.items[1], plate_id: 1 },
+      ],
+    });
+    render(<PrintLogModal archiveId={42} archiveName="Benchy" onClose={vi.fn()} />);
+    await waitFor(() => {
+      expect(screen.getByText('Plate 2')).toBeInTheDocument();
+    });
+    expect(screen.getByText('Plate 1')).toBeInTheDocument();
+    expect(screen.getByRole('columnheader', { name: 'Plate' })).toBeInTheDocument();
+  });
+
+  it('shows no plate column for a file only ever printed as plate 1', async () => {
+    vi.mocked(api.getArchiveRuns).mockResolvedValue({
+      total: 2,
+      items: [
+        { ...sampleRuns.items[0], plate_id: 1 },
+        { ...sampleRuns.items[1], plate_id: null },
+      ],
+    });
+    render(<PrintLogModal archiveId={42} archiveName="Benchy" onClose={vi.fn()} />);
+    await waitFor(() => {
+      expect(screen.getByText(/100\.0 g/)).toBeInTheDocument();
+    });
+    expect(screen.queryByRole('columnheader', { name: 'Plate' })).not.toBeInTheDocument();
+  });
+
   it('shows the empty state when there are no runs', async () => {
     vi.mocked(api.getArchiveRuns).mockResolvedValue({ total: 0, items: [] });
     render(<PrintLogModal archiveId={42} archiveName="Benchy" onClose={vi.fn()} />);

+ 15 - 0
frontend/src/__tests__/pages/ArchivesPage.test.tsx

@@ -327,6 +327,21 @@ describe('ArchivesPage', () => {
       });
     });
 
+    it('shows the picture of the plate the title names', async () => {
+      // A file printed again shows its latest run's plate; the stored
+      // thumbnail is the first plate's.
+      server.use(
+        http.get('/api/v1/archives/', () =>
+          HttpResponse.json([{ ...mockArchives[1], plate_id: 3 }])
+        )
+      );
+
+      render(<ArchivesPage />);
+
+      const img = await screen.findByAltText('Bracket v2');
+      expect(img.getAttribute('src')).toContain(`/archives/${mockArchives[1].id}/plate-thumbnail/3`);
+    });
+
     it('leaves the title alone for a print on plate 1', async () => {
       // The queue records a plate for single-plate files too, so an ungated
       // label reads "Plate 1" on ordinary prints (#2796).

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

@@ -959,6 +959,7 @@ export interface PrintLogEntry {
   id: number;
   archive_id: number | null;
   print_name: string | null;
+  plate_id?: number | null;
   printer_name: string | null;
   printer_id: number | null;
   status: string;

+ 9 - 0
frontend/src/components/PrintLogTable.tsx

@@ -38,6 +38,9 @@ export function PrintLogTable({ archiveId }: PrintLogTableProps) {
   }
 
   const runs = data?.items || [];
+  // A file with one plate names plate 1 on every run: the column only shows
+  // once a run printed another plate.
+  const showPlate = runs.some((run) => run.plate_id != null && run.plate_id > 1);
   if (runs.length === 0) {
     return (
       <p className="text-sm text-bambu-gray italic py-2">
@@ -52,6 +55,7 @@ export function PrintLogTable({ archiveId }: PrintLogTableProps) {
         <thead>
           <tr className="text-bambu-gray border-b border-bambu-dark-tertiary">
             <th className="text-left py-1.5 pr-2 font-medium">{t('archives.runLog.col.date')}</th>
+            {showPlate && <th className="text-left py-1.5 pr-2 font-medium">{t('archives.runLog.col.plate')}</th>}
             <th className="text-left py-1.5 pr-2 font-medium">{t('archives.runLog.col.status')}</th>
             <th className="text-right py-1.5 pr-2 font-medium">{t('archives.runLog.col.duration')}</th>
             <th className="text-right py-1.5 pr-2 font-medium">{t('archives.runLog.col.filament')}</th>
@@ -74,6 +78,11 @@ export function PrintLogTable({ archiveId }: PrintLogTableProps) {
                 <td className="py-1.5 pr-2 text-bambu-gray-light">
                   {formatDate(run.started_at || run.created_at)}
                 </td>
+                {showPlate && (
+                  <td className="py-1.5 pr-2 text-bambu-gray-light">
+                    {run.plate_id != null ? t('archives.card.plateNumber', { index: run.plate_id }) : '—'}
+                  </td>
+                )}
                 <td className={`py-1.5 pr-2 font-medium ${statusClass}`}>
                   {t(`archives.runLog.status.${run.status}`, { defaultValue: run.status })}
                   {run.failure_reason && (

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

@@ -1226,6 +1226,7 @@ export default {
         duration: 'Dauer',
         filament: 'Filament',
         cost: 'Kosten',
+        plate: 'Platte',
       },
       status: {
         completed: 'Abgeschlossen',

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

@@ -1233,6 +1233,7 @@ export default {
         duration: 'Duration',
         filament: 'Filament',
         cost: 'Cost',
+        plate: 'Plate',
       },
       status: {
         completed: 'Completed',

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

@@ -1226,6 +1226,7 @@ export default {
         duration: 'Duración',
         filament: 'Filamento',
         cost: 'Coste',
+        plate: 'Cama',
       },
       status: {
         completed: 'Completada',

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

@@ -1226,6 +1226,7 @@ export default {
         duration: 'Durée',
         filament: 'Filament',
         cost: 'Coût',
+        plate: 'Plateau',
       },
       status: {
         completed: 'Terminé',

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

@@ -1226,6 +1226,7 @@ export default {
         duration: 'Durata',
         filament: 'Filamento',
         cost: 'Costo',
+        plate: 'Piatto',
       },
       status: {
         completed: 'Completata',

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

@@ -1225,6 +1225,7 @@ export default {
         duration: '時間',
         filament: 'フィラメント',
         cost: 'コスト',
+        plate: 'プレート',
       },
       status: {
         completed: '完了',

+ 2 - 1
frontend/src/i18n/locales/ko.ts

@@ -1281,7 +1281,8 @@ export default {
         status: '상태',
         duration: '소요 시간',
         filament: '필라멘트',
-        cost: '비용'
+        cost: '비용',
+        plate: '플레이트'
       },
       status: {
         completed: '완료',

+ 1 - 0
frontend/src/i18n/locales/nl.ts

@@ -1231,6 +1231,7 @@ export default {
         duration: 'Duur',
         filament: 'Filament',
         cost: 'Kosten',
+        plate: 'Plaat',
       },
       status: {
         completed: 'Voltooid',

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

@@ -1226,6 +1226,7 @@ export default {
         duration: 'Duração',
         filament: 'Filamento',
         cost: 'Custo',
+        plate: 'Placa',
       },
       status: {
         completed: 'Concluída',

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

@@ -1181,6 +1181,7 @@ export default {
         duration: "Длительность",
         filament: "Филамент",
         cost: "Стоимость",
+        plate: "Пластина",
       },
       status: {
         completed: "Завершено",

+ 1 - 0
frontend/src/i18n/locales/sv.ts

@@ -1231,6 +1231,7 @@ export default {
         duration: 'Varaktighet',
         filament: 'Filament',
         cost: 'Kostnad',
+        plate: 'Platta',
       },
       status: {
         completed: 'Slutförd',

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

@@ -1226,6 +1226,7 @@ export default {
         duration: 'Süre',
         filament: 'Filament',
         cost: 'Maliyet',
+        plate: 'Plaka',
       },
       status: {
         completed: 'Tamamlandı',

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

@@ -1230,6 +1230,7 @@ export default {
         duration: "Тривалість",
         filament: "Філамент",
         cost: "Вартість",
+        plate: "Пластина",
       },
       status: {
         completed: "Виконано",

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

@@ -1226,6 +1226,7 @@ export default {
         duration: '时长',
         filament: '耗材',
         cost: '成本',
+        plate: '板',
       },
       status: {
         completed: '已完成',

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

@@ -1226,6 +1226,7 @@ export default {
         duration: '時長',
         filament: '耗材',
         cost: '成本',
+        plate: '板',
       },
       status: {
         completed: '已完成',

+ 12 - 1
frontend/src/pages/ArchivesPage.tsx

@@ -922,8 +922,19 @@ function ArchiveCard({
             src={
               currentPlateIndex !== null && plates.length > 0
                 ? api.getArchivePlateThumbnail(archive.id, plates[displayPlateIndex]?.index ?? 0)
-                : api.getArchiveThumbnail(archive.id)
+                : // The plate the title names: a file printed again shows its
+                  // latest run's plate, the stored picture is the first's.
+                  archive.plate_id != null && archive.plate_id > 1
+                  ? api.getArchivePlateThumbnail(archive.id, archive.plate_id)
+                  : api.getArchiveThumbnail(archive.id)
             }
+            onError={(e) => {
+              // A file without per-plate pictures: the stored one.
+              const img = e.currentTarget;
+              if (img.dataset.fellBack) return;
+              img.dataset.fellBack = '1';
+              img.src = api.getArchiveThumbnail(archive.id);
+            }}
             loading="lazy"
             alt={archive.print_name || archive.filename}
             className="w-full h-full object-cover"

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/ArchivesPage-QsB62ebu.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/CamWallPage-DDwb0P0U.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/CameraPage-Dr1P9O85.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/ConfirmOutcomeDialog-C2TGy01-.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/ConfirmOutcomeDialog-DQcbXiuf.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/FileManagerPage-DpDGTgZx.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/InventoryPage-DQWlUrqc.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/Layout-B1sOAU5B.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/ModelSourcesPage-ChL0-1s3.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/PhotoGalleryModal-BuupV3Ho.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/PrintersPage-Bzhc9aaH.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/SettingsPage-CsLdtL8p.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/SliceModal-8vVtLR8-.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/SpoolBuddyInventoryPage--qzMHNDp.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 1 - 1
static/assets/SpoolBuddySettingsPage-uoy_ItK2.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/de-Bk0IiWpM.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/de-CkWtaO_K.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/es-BjYoCL6b.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/es-CWw9g0wR.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/fr-8h712JuF.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/fr-uh6aBZ28.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/index-CtyE4Y_n.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/it-BTV4EN5M.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/it-DS8-WpFb.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/ja-C2wYtnNs.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/ja-D7msVZp_.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/ko-D3jUJSC1.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/ko-_Doaq3sb.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/locationSensorDefaults-CpTl7DcE.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/nl-Bv635sdy.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/nl-SJk3IPjL.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/pt-BR-BqpXo1eQ.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/pt-BR-DInP_RiA.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/ru-BWbkfzaI.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/ru-Bl9WHwgB.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/spoolCost-DROtpkGK.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/sv-BybQ0GC3.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/sv-DmMBi90T.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/tr-CwYXTEB8.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/tr-DjyaJxtE.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/uk-Ci5lhDIq.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/uk-DCVYl2Ma.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/zh-CN-7goq5QxD.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/zh-CN-BO-CGQ8a.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/zh-TW-BSKDtRbu.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/zh-TW-Cl8sEy_e.js


+ 3 - 3
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-DqrM3QsY.js"></script>
+    <script type="module" crossorigin src="/assets/index-CtyE4Y_n.js"></script>
     <link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-aKtaBQYM.js">
     <link rel="modulepreload" crossorigin href="/assets/client-8XqSBxc-.js">
     <link rel="modulepreload" crossorigin href="/assets/react-dom-DqnGEPFR.js">
@@ -110,7 +110,7 @@
     <link rel="modulepreload" crossorigin href="/assets/trash-2-Cp6fGnE3.js">
     <link rel="modulepreload" crossorigin href="/assets/weight-CLMgycu4.js">
     <link rel="modulepreload" crossorigin href="/assets/projectTree-C5LrJHZP.js">
-    <link rel="modulepreload" crossorigin href="/assets/ConfirmOutcomeDialog-C2TGy01-.js">
+    <link rel="modulepreload" crossorigin href="/assets/ConfirmOutcomeDialog-DQcbXiuf.js">
     <link rel="modulepreload" crossorigin href="/assets/key-hObWW3DC.js">
     <link rel="modulepreload" crossorigin href="/assets/log-out-CP-HYUYR.js">
     <link rel="modulepreload" crossorigin href="/assets/shield-alert-BwrIiOG8.js">
@@ -122,7 +122,7 @@
     <link rel="modulepreload" crossorigin href="/assets/useColorCatalogVersion-kwE5kmQs.js">
     <link rel="modulepreload" crossorigin href="/assets/circle-minus-BPSMaNFL.js">
     <link rel="modulepreload" crossorigin href="/assets/ConnectionDiagnostic-CirSjZ36.js">
-    <link rel="modulepreload" crossorigin href="/assets/Layout-DrYuO86O.js">
+    <link rel="modulepreload" crossorigin href="/assets/Layout-B1sOAU5B.js">
     <link rel="stylesheet" crossorigin href="/assets/index-Bm33gdbH.css">
   </head>
   <body>

Algúns arquivos non se mostraron porque demasiados arquivos cambiaron neste cambio