Bläddra i källkod

fix(queue): persist selected plate to the archive; reconcile archive on offline stop (#2603)

A print queued from a specific plate of a multi-plate 3MF showed as Plate 1
in Print History after cancellation: the archive derives its plate from the
filename, but a whole multi-plate 3MF uploads under one name with no plate
suffix, so the parser defaulted to plate 1 and nothing copied the queue
item's plate_id onto the archive (which had no plate field).

Add a nullable print_archives.plate_id, copy it from the queue item at
dispatch (archive- and library-file paths), expose it in the archive API,
and render it in Print History. A startup backfill copies the plate onto
existing archives from their linked queue rows. Column add + backfill are
identical on SQLite and Postgres.

Also fix a related lifecycle bug: stopping a printing item while the printer
was offline left the linked archive stuck at "printing" (queue row
cancelled, but no MQTT completion ever arrives to reconcile the archive).
The offline-stop path now closes the archive out directly; the online path
still defers to the MQTT completion event.
maziggy 1 månad sedan
förälder
incheckning
83a7b75b14

+ 1 - 0
CHANGELOG.md

@@ -8,6 +8,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **Orca Cloud profile sync now connects by approving a code instead of the copy-paste sign-in** — Connecting Bambuddy to Orca Cloud used to mean opening an OAuth sign-in in a new tab, watching it redirect to a `localhost` URL that fails to load, then copying that dead URL out of the address bar and pasting it back into Bambuddy. That dance existed only because Orca's auth backend (Supabase) accepts no redirect target other than `localhost`, and the deliberately-broken redirect page confused nearly everyone who reached it. OrcaSlicer has since shipped a first-class external-app pairing API (the OAuth 2.0 Device Authorization Grant, RFC 8628), so the flow is now: click **Connect**, approve a short code on your Orca Cloud settings page, and Bambuddy pairs itself — no redirect, no paste, no client secret, and it behaves identically from a LAN IP, `localhost`, or behind a reverse proxy. Bambuddy requests **read-only** access (it only lists and views your Orca Cloud profiles), keeps the pairing alive with the API's rotating refresh tokens (validated end-to-end against Orca's staging and production servers), and stores nothing beyond the issued token pair. The profile list and detail views are unchanged, so nothing downstream of the connect step looks different. The old paste-based sign-in and the email/password fallback are removed. Points at production Orca Cloud by default; `ORCA_CLOUD_API_BASE` overrides the endpoint for testing.
 - **Orca Cloud profile sync now connects by approving a code instead of the copy-paste sign-in** — Connecting Bambuddy to Orca Cloud used to mean opening an OAuth sign-in in a new tab, watching it redirect to a `localhost` URL that fails to load, then copying that dead URL out of the address bar and pasting it back into Bambuddy. That dance existed only because Orca's auth backend (Supabase) accepts no redirect target other than `localhost`, and the deliberately-broken redirect page confused nearly everyone who reached it. OrcaSlicer has since shipped a first-class external-app pairing API (the OAuth 2.0 Device Authorization Grant, RFC 8628), so the flow is now: click **Connect**, approve a short code on your Orca Cloud settings page, and Bambuddy pairs itself — no redirect, no paste, no client secret, and it behaves identically from a LAN IP, `localhost`, or behind a reverse proxy. Bambuddy requests **read-only** access (it only lists and views your Orca Cloud profiles), keeps the pairing alive with the API's rotating refresh tokens (validated end-to-end against Orca's staging and production servers), and stores nothing beyond the issued token pair. The profile list and detail views are unchanged, so nothing downstream of the connect step looks different. The old paste-based sign-in and the email/password fallback are removed. Points at production Orca Cloud by default; `ORCA_CLOUD_API_BASE` overrides the endpoint for testing.
 
 
 ### Fixed
 ### Fixed
+- **Multi-plate queue prints lost the selected plate in Print History and a stopped-while-offline print stayed "printing" (#2603, reporter @Jostxxl)** — Cancelling a print queued from a specific plate of a multi-plate 3MF showed it in Print History as **Plate 1**, so you couldn't tell which plate to requeue. **Root cause.** The archive derives its plate from the *filename*, but a whole multi-plate 3MF uploads under one name with no plate suffix, so the parser defaulted to plate 1 and `extra_data` held all-plates aggregate metadata; the queue row kept the correct plate but nothing copied it onto the archive, which had no plate field at all. **Fix.** `print_archives` gains a nullable `plate_id`, copied from the queue item at dispatch (both the archive-based and library-file paths), exposed in the archive API, and rendered in Print History (falling back to no plate label only when genuinely unknown). A startup backfill copies the plate onto existing archives from their linked queue rows, so already-cancelled prints recover their plate. Additionally, **stopping a printing item while the printer was offline left the linked archive stuck at "printing"** — the queue row was cancelled but, with no printer to send an MQTT completion, nothing ever reconciled the archive. The offline-stop path now closes the archive out directly (status `cancelled`, `failure_reason` "Stopped by user (printer was offline)"); the online path is unchanged and still leaves the archive to the MQTT completion event. Column add + backfill are identical on SQLite and Postgres. Covered by tests for plate persistence, the backfill (including no-clobber/idempotency), and the offline vs online stop reconcile.
 - **`queue_max_concurrent_uploads` behaved as a per-batch cap instead of a refillable pool (#2602, reporter @Jostxxl)** — On a large farm, unused upload slots sat idle whenever any upload from the current batch was still running. **Root cause.** `check_queue()` awaited `_dispatch_selected()`, which awaited `asyncio.gather()` over the whole selected batch before returning — so the scheduler's run loop was blocked until the *slowest* FTP transfer in the batch finished. A 96 MB 3MF that took 513 s to upload left 15 of 16 configured slots unused for 8.5 minutes on a 93-printer farm, even as other printers came free; jobs that became eligible during the long upload couldn't be dispatched. The batch-await was load-bearing for one reason: `_start_print` flips a row `pending → printing` only *after* its upload completes, so returning early would have let the next pass re-dispatch the still-`pending` in-flight rows. **Fix.** Uploads now run as independent background tasks tracked in a `_inflight` pool. Each tick excludes in-flight item rows (and their printers) from selection, launches at most `limit − len(_inflight)` new uploads, and returns immediately — so a freed slot refills on the next fast (3 s) tick instead of waiting out the whole batch, and the configured limit finally behaves as a continuously-refillable worker pool. The no-double-dispatch invariant the batch-await used to provide is now carried by the in-flight exclusion; the `pending → printing` CAS, the busy-printer guard (#2598), the per-printer dispatch hold, auto-drying exclusion (in-flight printers stay out, including on the no-pending-items path), and per-item failure isolation are all preserved and run per task. Investigated with the reporter's large-farm hotfix and reproduction; covered by rewritten pool tests (cap holds across refills, freed slot refills, in-flight item/printer excluded from re-selection, check_queue returns without awaiting uploads).
 - **`queue_max_concurrent_uploads` behaved as a per-batch cap instead of a refillable pool (#2602, reporter @Jostxxl)** — On a large farm, unused upload slots sat idle whenever any upload from the current batch was still running. **Root cause.** `check_queue()` awaited `_dispatch_selected()`, which awaited `asyncio.gather()` over the whole selected batch before returning — so the scheduler's run loop was blocked until the *slowest* FTP transfer in the batch finished. A 96 MB 3MF that took 513 s to upload left 15 of 16 configured slots unused for 8.5 minutes on a 93-printer farm, even as other printers came free; jobs that became eligible during the long upload couldn't be dispatched. The batch-await was load-bearing for one reason: `_start_print` flips a row `pending → printing` only *after* its upload completes, so returning early would have let the next pass re-dispatch the still-`pending` in-flight rows. **Fix.** Uploads now run as independent background tasks tracked in a `_inflight` pool. Each tick excludes in-flight item rows (and their printers) from selection, launches at most `limit − len(_inflight)` new uploads, and returns immediately — so a freed slot refills on the next fast (3 s) tick instead of waiting out the whole batch, and the configured limit finally behaves as a continuously-refillable worker pool. The no-double-dispatch invariant the batch-await used to provide is now carried by the in-flight exclusion; the `pending → printing` CAS, the busy-printer guard (#2598), the per-printer dispatch hold, auto-drying exclusion (in-flight printers stay out, including on the no-pending-items path), and per-item failure isolation are all preserved and run per task. Investigated with the reporter's large-farm hotfix and reproduction; covered by rewritten pool tests (cap holds across refills, freed slot refills, in-flight item/printer excluded from re-selection, check_queue returns without awaiting uploads).
 - **Configuring a built-in/generic filament on an AMS slot reverted to the old profile a moment later (#2604, reporter @Jostxxl)** — Selecting a built-in preset (e.g. Generic ABS) through **Printer → AMS slot → Configure** briefly showed the new material on the printer, then the slot snapped back to whatever was there before (e.g. an old Generic PETG). **Root cause.** The Configure AMS Slot modal sends built-in, local, and Orca-generic presets with a `GF*` `tray_info_idx` but an **empty** `setting_id` (those presets carry no Bambu Cloud setting id of their own), and the `configure_ams_slot` route forwarded that empty value straight to `ams_filament_setting`. The firmware treats a slot that has a filament id but no setting id as half-configured: it accepts the update, then reverts to its previously stored profile. The inventory/assignment path already guards against this by deriving the setting id from the filament id, but the manual Configure path didn't, leaving two inconsistent code paths. **Fix.** `configure_ams_slot` now back-fills `setting_id` from the resolved `tray_info_idx` via `filament_id_to_setting_id` whenever the client sent none (e.g. `GFB99` → `GFSB99`), mirroring the inventory path. Doing it server-side also protects API callers and any future frontend. `P*` user presets and already-`GFS*` values are left untouched, and an explicitly-supplied `setting_id` (including the `PFUS*` pair) still passes through unchanged. Covered by tests for the built-in empty-`setting_id` case and the material-only generic-fallback case both publishing a derived `GFS*` id.
 - **Configuring a built-in/generic filament on an AMS slot reverted to the old profile a moment later (#2604, reporter @Jostxxl)** — Selecting a built-in preset (e.g. Generic ABS) through **Printer → AMS slot → Configure** briefly showed the new material on the printer, then the slot snapped back to whatever was there before (e.g. an old Generic PETG). **Root cause.** The Configure AMS Slot modal sends built-in, local, and Orca-generic presets with a `GF*` `tray_info_idx` but an **empty** `setting_id` (those presets carry no Bambu Cloud setting id of their own), and the `configure_ams_slot` route forwarded that empty value straight to `ams_filament_setting`. The firmware treats a slot that has a filament id but no setting id as half-configured: it accepts the update, then reverts to its previously stored profile. The inventory/assignment path already guards against this by deriving the setting id from the filament id, but the manual Configure path didn't, leaving two inconsistent code paths. **Fix.** `configure_ams_slot` now back-fills `setting_id` from the resolved `tray_info_idx` via `filament_id_to_setting_id` whenever the client sent none (e.g. `GFB99` → `GFSB99`), mirroring the inventory path. Doing it server-side also protects API callers and any future frontend. `P*` user presets and already-`GFS*` values are left untouched, and an explicitly-supplied `setting_id` (including the `PFUS*` pair) still passes through unchanged. Covered by tests for the built-in empty-`setting_id` case and the material-only generic-fallback case both publishing a derived `GFS*` id.
 - **The HT-A (AMS-HT) spool vanished a few seconds after every power-on (#2594, reporter @GuillaumeHouba)** — On an H2C, the spool in the HT-A high-temp AMS on the left nozzle showed correctly with its RFID assignment on power-on, then disappeared seconds later; the regular AMS spools stayed. **Root cause.** The AMS merge in `_handle_ams_data` clears a tray when it receives a partial `{id, state}` update whose `state != 11` — the rule that lets 4-slot AMS units (e.g. H2D) report an emptied slot with just `{id, state}` and no `tray_type` (#784), where `11` = loaded. But an **AMS-HT** (single-tray high-temp dry box, unit id ≥ 128) reports its *loaded* tray as `state=9`, not 11 — it doesn't feed filament into a shared buffer the way a 4-slot AMS does. So the partial `{id:0, state:9}` the printer sends for the HT tray on power-on was misread as "slot emptied," and Bambuddy wiped the tray's `tray_type` / RFID / Spoolman assignment. The support log showed it plainly: every "state=9 (not loaded) — clearing stale tray data" was on AMS 128, never on the regular AMS unit 0 (which correctly reports 11). **Fix.** The `state != 11 → empty` heuristic is now skipped for AMS-HT units (id ≥ 128); their differing single-tray state semantics mean a partial state update must not clear a present spool. A genuine HT spool removal still clears through the explicit `tray_type == ""` update and the `tray_exist_bits` cleanup, both unchanged, and regular AMS behavior (id < 128) is untouched. Covered by tests for the HT tray surviving a `state=9` partial, the HT still clearing on an explicit empty, and the existing regular-AMS `state=9`/`10`/`11` cases.
 - **The HT-A (AMS-HT) spool vanished a few seconds after every power-on (#2594, reporter @GuillaumeHouba)** — On an H2C, the spool in the HT-A high-temp AMS on the left nozzle showed correctly with its RFID assignment on power-on, then disappeared seconds later; the regular AMS spools stayed. **Root cause.** The AMS merge in `_handle_ams_data` clears a tray when it receives a partial `{id, state}` update whose `state != 11` — the rule that lets 4-slot AMS units (e.g. H2D) report an emptied slot with just `{id, state}` and no `tray_type` (#784), where `11` = loaded. But an **AMS-HT** (single-tray high-temp dry box, unit id ≥ 128) reports its *loaded* tray as `state=9`, not 11 — it doesn't feed filament into a shared buffer the way a 4-slot AMS does. So the partial `{id:0, state:9}` the printer sends for the HT tray on power-on was misread as "slot emptied," and Bambuddy wiped the tray's `tray_type` / RFID / Spoolman assignment. The support log showed it plainly: every "state=9 (not loaded) — clearing stale tray data" was on AMS 128, never on the regular AMS unit 0 (which correctly reports 11). **Fix.** The `state != 11 → empty` heuristic is now skipped for AMS-HT units (id ≥ 128); their differing single-tray state semantics mean a partial state update must not clear a present spool. A genuine HT spool removal still clears through the explicit `tray_type == ""` update and the `tray_exist_bits` cleanup, both unchanged, and regular AMS behavior (id < 128) is untouched. Covered by tests for the HT tray surviving a `state=9` partial, the HT still clearing on an explicit empty, and the existing regular-AMS `state=9`/`10`/`11` cases.

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

@@ -1374,6 +1374,22 @@ async def stop_queue_item(
     item.status = "cancelled"
     item.status = "cancelled"
     item.completed_at = datetime.now(timezone.utc)
     item.completed_at = datetime.now(timezone.utc)
     item.error_message = "Stopped by user" if stop_sent else "Stopped by user (printer was offline)"
     item.error_message = "Stopped by user" if stop_sent else "Stopped by user (printer was offline)"
+
+    # Reconcile the linked archive when the printer is offline (#2603). When the
+    # stop command reaches the printer it later reports the stop over MQTT and
+    # on_print_complete flips the archive to cancelled/failed. When the printer is
+    # offline no such event ever arrives, so the archive would stay "printing"
+    # forever (queue row cancelled, archive still printing — the reporter's
+    # archive 436). Close it out here, mirroring what the MQTT path would have
+    # done. Only touch a still-"printing" archive so we never overwrite a real
+    # completion that raced in.
+    if not stop_sent and item.archive_id:
+        archive = await db.get(PrintArchive, item.archive_id)
+        if archive and archive.status == "printing":
+            archive.status = "cancelled"
+            archive.completed_at = datetime.now(timezone.utc)
+            archive.failure_reason = "Stopped by user (printer was offline)"
+
     await db.commit()
     await db.commit()
 
 
     logger.info("Stopped printing queue item %s (stop command sent: %s)", item_id, stop_sent)
     logger.info("Stopped printing queue item %s (stop command sent: %s)", item_id, stop_sent)

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

@@ -852,6 +852,13 @@ async def run_migrations(conn):
     # Migration: Add f3d_path column to print_archives for Fusion 360 design files
     # Migration: Add f3d_path column to print_archives for Fusion 360 design files
     await _safe_execute(conn, "ALTER TABLE print_archives ADD COLUMN f3d_path VARCHAR(500)")
     await _safe_execute(conn, "ALTER TABLE print_archives ADD COLUMN f3d_path VARCHAR(500)")
 
 
+    # Migration: Add plate_id column to print_archives (#2603). The selected plate
+    # of a multi-plate 3MF is copied from the queue item at dispatch so Print
+    # History can show the actual plate instead of falling back to Plate 1.
+    # Nullable, no default — identical DDL on SQLite and Postgres. Backfilled from
+    # linked queue rows below.
+    await _safe_execute(conn, "ALTER TABLE print_archives ADD COLUMN plate_id INTEGER")
+
     # Migration: Add on_maintenance_due column to notification_providers
     # Migration: Add on_maintenance_due column to notification_providers
     await _safe_execute(conn, "ALTER TABLE notification_providers ADD COLUMN on_maintenance_due BOOLEAN DEFAULT 0")
     await _safe_execute(conn, "ALTER TABLE notification_providers ADD COLUMN on_maintenance_due BOOLEAN DEFAULT 0")
 
 
@@ -3509,6 +3516,56 @@ async def run_migrations(conn):
     # regardless, so even a NULL row could not disable the retry cap.
     # regardless, so even a NULL row could not disable the retry cap.
     await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN dispatch_attempts INTEGER DEFAULT 0")
     await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN dispatch_attempts INTEGER DEFAULT 0")
 
 
+    # Backfill: copy the selected plate from linked queue rows onto their archives
+    # (#2603). Recovers the plate for archives created before print_archives had a
+    # plate_id column, wherever the queue row still points at the archive and
+    # carries a plate. Runs here — after every print_queue column migration
+    # (plate_id, archive_id) — because it reads print_queue.plate_id, which is
+    # added far earlier in this function but must exist before this DML runs on a
+    # first-ever migration pass. Correlated-subquery form so the DML is identical
+    # on SQLite and Postgres; the WHERE plate_id IS NULL guard makes it idempotent
+    # and keeps it from clobbering values set on later runs.
+    async with conn.begin_nested():
+        # Only do any work (and, on SQLite, the FTS rebuild below) when there is
+        # actually a plate to recover — so this is a one-off cost on the upgrade
+        # boot, not an every-boot tax once every archive is backfilled.
+        has_work = (
+            await conn.execute(
+                text(
+                    "SELECT 1 FROM print_archives a "
+                    "JOIN print_queue q ON q.archive_id = a.id "
+                    "WHERE a.plate_id IS NULL AND q.plate_id IS NOT NULL "
+                    "LIMIT 1"
+                )
+            )
+        ).first() is not None
+        if has_work:
+            # SQLite: print_archives has an external-content FTS index (archive_fts,
+            # created above) whose AFTER UPDATE trigger issues an FTS 'delete' for
+            # the row. Archives created before that table existed were never indexed
+            # (its creation runs no rebuild), and updating an un-indexed row trips
+            # "database disk image is malformed". plate_id isn't even an FTS column,
+            # so the trigger's re-index is pointless here — but it still fires. Rebuild
+            # the index from the content table first so every row is present and the
+            # trigger's 'delete' is well-defined. Postgres has no such FTS table.
+            if is_sqlite():
+                await conn.execute(text("INSERT INTO archive_fts(archive_fts) VALUES('rebuild')"))
+            await conn.execute(
+                text(
+                    "UPDATE print_archives "
+                    "SET plate_id = ("
+                    "  SELECT pq.plate_id FROM print_queue pq "
+                    "  WHERE pq.archive_id = print_archives.id AND pq.plate_id IS NOT NULL "
+                    "  LIMIT 1"
+                    ") "
+                    "WHERE plate_id IS NULL "
+                    "AND EXISTS ("
+                    "  SELECT 1 FROM print_queue pq "
+                    "  WHERE pq.archive_id = print_archives.id AND pq.plate_id IS NOT NULL"
+                    ")"
+                )
+            )
+
     # Migration: Disambiguate the four ``user_print_*`` notification template
     # Migration: Disambiguate the four ``user_print_*`` notification template
     # names by appending " Email" (#1792). See ``_migrate_rename_user_print_template_names``.
     # names by appending " Email" (#1792). See ``_migrate_rename_user_print_template_names``.
     await _migrate_rename_user_print_template_names(conn)
     await _migrate_rename_user_print_template_names(conn)

+ 8 - 0
backend/app/models/archive.py

@@ -56,6 +56,14 @@ class PrintArchive(Base):
     # print and keep the original row instead of cancel-then-create.
     # print and keep the original row instead of cancel-then-create.
     subtask_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
     subtask_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
 
 
+    # Which plate of a multi-plate 3MF this print was for (1-based), copied from
+    # the queue item at dispatch (#2603). A whole multi-plate 3MF is uploaded
+    # under one filename with no plate suffix, so the parser can't recover the
+    # selected plate and extra_data holds all-plates aggregate metadata; without
+    # this the history UI can't tell which plate was printed and falls back to
+    # Plate 1. NULL for archives with no specific selected plate.
+    plate_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
+
     # Extended metadata (JSON blob for flexibility)
     # Extended metadata (JSON blob for flexibility)
     extra_data: Mapped[dict | None] = mapped_column(JSON)
     extra_data: Mapped[dict | None] = mapped_column(JSON)
 
 

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

@@ -55,6 +55,7 @@ class ArchiveResponse(BaseModel):
     object_count: int | None = None
     object_count: int | None = None
 
 
     print_name: str | None
     print_name: str | None
+    plate_id: int | None = None  # Selected plate of a multi-plate 3MF (#2603)
     print_time_seconds: int | None  # Estimated time from slicer
     print_time_seconds: int | None  # Estimated time from slicer
     actual_time_seconds: int | None = None  # Computed from started_at/completed_at
     actual_time_seconds: int | None = None  # Computed from started_at/completed_at
     # Percentage: 100 = perfect, >100 = faster than estimated
     # Percentage: 100 = perfect, >100 = faster than estimated

+ 2 - 0
backend/app/services/archive.py

@@ -1142,6 +1142,7 @@ class ArchiveService:
         project_id: int | None = None,
         project_id: int | None = None,
         subtask_id: str | None = None,
         subtask_id: str | None = None,
         prefer_filename_for_name: bool = False,
         prefer_filename_for_name: bool = False,
+        plate_id: int | None = None,
     ) -> PrintArchive | None:
     ) -> PrintArchive | None:
         """Archive a 3MF file with metadata.
         """Archive a 3MF file with metadata.
 
 
@@ -1314,6 +1315,7 @@ class ArchiveService:
             created_by_id=created_by_id,
             created_by_id=created_by_id,
             project_id=project_id,
             project_id=project_id,
             subtask_id=subtask_id,
             subtask_id=subtask_id,
+            plate_id=plate_id,
         )
         )
 
 
         self.db.add(archive)
         self.db.add(archive)

+ 9 - 0
backend/app/services/print_scheduler.py

@@ -2965,6 +2965,14 @@ class PrintScheduler:
                 await self._power_off_if_needed(db, item)
                 await self._power_off_if_needed(db, item)
                 return
                 return
 
 
+            # 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:
+                archive.plate_id = item.plate_id
+
             file_path = settings.base_dir / archive.file_path
             file_path = settings.base_dir / archive.file_path
             filename = archive.filename
             filename = archive.filename
 
 
@@ -2997,6 +3005,7 @@ class PrintScheduler:
                     original_filename=filename,
                     original_filename=filename,
                     created_by_id=item.created_by_id,
                     created_by_id=item.created_by_id,
                     project_id=item.project_id,
                     project_id=item.project_id,
+                    plate_id=item.plate_id,  # selected plate → Print History (#2603)
                 )
                 )
                 if archive:
                 if archive:
                     item.archive_id = archive.id
                     item.archive_id = archive.id

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

@@ -3164,3 +3164,75 @@ class TestForceColorOverridesAreScopedToThePlate:
         )
         )
         assert response.status_code == 200
         assert response.status_code == 200
         assert [o["color_name"] for o in response.json()["filament_overrides"]] == ["Sunshine Yellow"]
         assert [o["color_name"] for o in response.json()["filament_overrides"]] == ["Sunshine Yellow"]
+
+
+@pytest.mark.asyncio
+async def test_stop_offline_reconciles_linked_archive_status_2603(
+    async_client: AsyncClient, printer_factory, archive_factory, db_session
+):
+    """Stopping a printing item while the printer is offline must also close out its
+    archive (#2603).
+
+    When the stop command reaches the printer, the later MQTT completion event flips
+    the archive to cancelled. When the printer is offline no such event ever arrives,
+    so without this the archive stays "printing" forever while the queue row is
+    already cancelled — the reporter's archive 436. The offline branch reconciles the
+    archive directly.
+    """
+    from unittest.mock import MagicMock, patch
+
+    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=22, filename="heart 3.gcode.3mf", with_run=False
+    )
+    item = PrintQueueItem(printer_id=printer.id, archive_id=archive.id, status="printing")
+    db_session.add(item)
+    await db_session.commit()
+    await db_session.refresh(item)
+
+    # stop_print returns False => printer offline / not connected.
+    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
+    await db_session.refresh(item)
+    await db_session.refresh(archive)
+    assert item.status == "cancelled"
+    assert archive.status == "cancelled", "an offline stop must reconcile the archive, not leave it 'printing'"
+    assert archive.completed_at is not None
+    assert archive.failure_reason == "Stopped by user (printer was offline)"
+
+
+@pytest.mark.asyncio
+async def test_stop_online_leaves_archive_for_mqtt_to_reconcile_2603(
+    async_client: AsyncClient, printer_factory, archive_factory, db_session
+):
+    """When the stop command reaches the printer, the archive is left to the MQTT
+    completion path — the offline reconcile must NOT fire and pre-empt it."""
+    from unittest.mock import MagicMock, patch
+
+    from backend.app.models.print_queue import PrintQueueItem
+
+    printer = await printer_factory(name="Online printer")
+    archive = await archive_factory(printer.id, status="printing", filename="heart 3.gcode.3mf", with_run=False)
+    item = PrintQueueItem(printer_id=printer.id, archive_id=archive.id, status="printing")
+    db_session.add(item)
+    await db_session.commit()
+    await db_session.refresh(item)
+
+    with patch(
+        "backend.app.services.printer_manager.printer_manager.stop_print",
+        MagicMock(return_value=True),
+    ):
+        resp = await async_client.post(f"/api/v1/queue/{item.id}/stop")
+
+    assert resp.status_code == 200
+    await db_session.refresh(item)
+    await db_session.refresh(archive)
+    assert item.status == "cancelled"
+    assert archive.status == "printing", "an online stop must leave the archive for the MQTT completion path"

+ 208 - 0
backend/tests/unit/test_archive_plate_2603.py

@@ -0,0 +1,208 @@
+"""Selected plate persists onto the archive and backfills from the queue (#2603).
+
+A whole multi-plate 3MF is uploaded under one filename with no plate suffix, so
+the archive parser can't recover the selected plate and Print History fell back
+to Plate 1. The queue row keeps the correct ``plate_id``; these tests cover
+copying it onto the archive, the startup backfill for pre-existing rows, and that
+``run_migrations`` applies the new column + backfill cleanly.
+"""
+
+import pytest
+from sqlalchemy import text
+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, run_migrations
+from backend.app.models.archive import PrintArchive
+from backend.app.models.print_queue import PrintQueueItem
+from backend.app.models.printer import Printer
+
+
+@pytest.fixture
+def force_sqlite_dialect(monkeypatch):
+    """Force the SQLite branch of run_migrations regardless of the test env's
+    DATABASE_URL (this sandbox points it at Postgres)."""
+    from backend.app.core import database as database_module, db_dialect
+
+    monkeypatch.setattr(db_dialect, "is_sqlite", lambda: True)
+    monkeypatch.setattr(db_dialect, "is_postgres", lambda: False)
+    monkeypatch.setattr(database_module, "is_sqlite", lambda: True)
+
+
+# The exact backfill statement run by run_migrations (kept in sync deliberately;
+# the run_migrations smoke test below exercises the real one).
+_BACKFILL_SQL = (
+    "UPDATE print_archives "
+    "SET plate_id = ("
+    "  SELECT pq.plate_id FROM print_queue pq "
+    "  WHERE pq.archive_id = print_archives.id AND pq.plate_id IS NOT NULL "
+    "  LIMIT 1"
+    ") "
+    "WHERE plate_id IS NULL "
+    "AND EXISTS ("
+    "  SELECT 1 FROM print_queue pq "
+    "  WHERE pq.archive_id = print_archives.id AND pq.plate_id IS NOT NULL"
+    ")"
+)
+
+
+@pytest.fixture
+async def sm():
+    engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
+    async with engine.begin() as conn:
+        await conn.run_sync(Base.metadata.create_all)
+    try:
+        yield async_sessionmaker(engine, expire_on_commit=False)
+    finally:
+        await engine.dispose()
+
+
+async def _printer(db) -> int:
+    printer = Printer(name="P", serial_number="S", ip_address="10.0.0.1", access_code="code", model="X1C")
+    db.add(printer)
+    await db.flush()
+    return printer.id
+
+
+@pytest.mark.asyncio
+async def test_archive_row_stores_plate_id(sm):
+    """The column round-trips a selected plate."""
+    async with sm() as db:
+        archive = PrintArchive(filename="heart 3.gcode.3mf", file_path="x", file_size=1, status="printing", plate_id=22)
+        db.add(archive)
+        await db.commit()
+        await db.refresh(archive)
+        assert archive.plate_id == 22
+
+
+@pytest.mark.asyncio
+async def test_backfill_copies_plate_from_linked_queue_row(sm):
+    """An archive with no plate inherits it from a queue row that still links to it."""
+    async with sm() as db:
+        printer_id = await _printer(db)
+        archive = PrintArchive(
+            filename="heart 3.gcode.3mf", file_path="x", file_size=1, status="cancelled", plate_id=None
+        )
+        db.add(archive)
+        await db.flush()
+        db.add(PrintQueueItem(printer_id=printer_id, archive_id=archive.id, status="cancelled", plate_id=22))
+        await db.commit()
+
+        await db.execute(text(_BACKFILL_SQL))
+        await db.commit()
+        await db.refresh(archive)
+        assert archive.plate_id == 22
+
+
+@pytest.mark.asyncio
+async def test_backfill_does_not_clobber_existing_plate_or_touch_unlinked(sm):
+    """Idempotent: a set plate is left alone, and an archive with no linked queue plate stays NULL."""
+    async with sm() as db:
+        printer_id = await _printer(db)
+        # Archive already carrying a plate; queue row disagrees — must not be overwritten.
+        set_archive = PrintArchive(filename="a.3mf", file_path="a", file_size=1, status="cancelled", plate_id=7)
+        # Archive with no linked queue plate at all — must stay NULL.
+        null_archive = PrintArchive(filename="b.3mf", file_path="b", file_size=1, status="completed", plate_id=None)
+        db.add_all([set_archive, null_archive])
+        await db.flush()
+        db.add(PrintQueueItem(printer_id=printer_id, archive_id=set_archive.id, status="cancelled", plate_id=3))
+        await db.commit()
+
+        # Run twice — second run must be a no-op.
+        await db.execute(text(_BACKFILL_SQL))
+        await db.execute(text(_BACKFILL_SQL))
+        await db.commit()
+        await db.refresh(set_archive)
+        await db.refresh(null_archive)
+        assert set_archive.plate_id == 7, "an archive that already had a plate must not be relabelled"
+        assert null_archive.plate_id is None, "an archive with no linked queue plate must stay NULL"
+
+
+@pytest.mark.asyncio
+async def test_run_migrations_adds_column_and_backfills_in_order(force_sqlite_dialect):
+    """End-to-end: run_migrations adds print_archives.plate_id and backfills it from
+    print_queue.plate_id without crashing (#2603).
+
+    Guards the migration *ordering*: the backfill reads print_queue.plate_id, which is
+    added earlier in run_migrations. If the backfill ran before that column existed
+    (as it did in the first draft), a first-ever migration pass would raise
+    "no such column: print_queue.plate_id" and abort startup. Running the full
+    migration here — twice — proves the order is correct and idempotent. Mirrors the
+    harness in test_cancellation_cascade_recovery_migration.py.
+    """
+    # run_migrations touches many tables; register the full model set so
+    # create_all builds the whole schema (imports for side effects only).
+    from backend.app.models import (  # noqa: F401
+        ams_history,
+        ams_label,
+        api_key,
+        auth_ephemeral,
+        color_catalog,
+        external_link,
+        filament,
+        group,
+        kprofile_note,
+        maintenance,
+        notification,
+        notification_template,
+        oidc_provider,
+        print_log,
+        project,
+        project_bom,
+        slot_preset,
+        smart_plug,
+        smart_plug_energy_snapshot,
+        sponsor_toast_state,
+        spool,
+        spool_assignment,
+        spool_catalog,
+        spool_k_profile,
+        spool_usage_history,
+        spoolbuddy_device,
+        spoolman_k_profile,
+        spoolman_slot_assignment,
+        user,
+        user_email_pref,
+        user_otp_code,
+        user_totp,
+        virtual_printer,
+    )
+
+    engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
+    try:
+        async with engine.begin() as conn:
+            await conn.run_sync(Base.metadata.create_all)
+
+        # Seed the archive + queue row BEFORE any migration pass — an existing
+        # install upgrading to this version. The archive predates the archive_fts
+        # FTS table (created inside run_migrations), so it is NOT indexed; the
+        # backfill's UPDATE would trip the external-content FTS 'delete' ("database
+        # disk image is malformed") unless the migration rebuilds the FTS index
+        # first. This is the exact shape that failed the force-color migration
+        # tests before the rebuild guard was added.
+        sm = async_sessionmaker(engine, expire_on_commit=False)
+        async with sm() as db:
+            printer_id = await _printer(db)
+            db.add(PrintArchive(id=436, filename="heart 3.gcode.3mf", file_path="x", file_size=1, status="cancelled"))
+            await db.flush()
+            db.add(PrintQueueItem(id=177, printer_id=printer_id, archive_id=436, status="cancelled", plate_id=22))
+            await db.commit()
+
+        # Upgrade boot: run_migrations must add print_archives.plate_id, rebuild the
+        # FTS index, and backfill the plate — without crashing. Also guards the
+        # ordering bug: the full migration runs top-to-bottom, so if the backfill
+        # preceded the print_queue.plate_id column it would raise "no such column".
+        async with engine.begin() as conn:
+            await run_migrations(conn)
+        async with sm() as db:
+            archive = await db.get(PrintArchive, 436)
+            assert archive.plate_id == 22, "run_migrations must backfill the archive's plate from its queue row"
+
+        # A further startup re-runs migrations — idempotent, plate unchanged, and
+        # (plate now set) the rebuild+backfill is skipped entirely.
+        async with engine.begin() as conn:
+            await run_migrations(conn)
+        async with sm() as db:
+            assert (await db.get(PrintArchive, 436)).plate_id == 22
+    finally:
+        await engine.dispose()

+ 3 - 1
backend/tests/unit/test_scheduler_cleanup_library.py

@@ -110,7 +110,9 @@ async def queue_factory(tmp_path):
 async def _dispatch_library_item(ctx, *, archive_failure=False, unlink_side_effect=None):
 async def _dispatch_library_item(ctx, *, archive_failure=False, unlink_side_effect=None):
     scheduler = PrintScheduler()
     scheduler = PrintScheduler()
 
 
-    async def archive_print(self, *, printer_id, source_file, original_filename, created_by_id=None, project_id=None):
+    async def archive_print(
+        self, *, printer_id, source_file, original_filename, created_by_id=None, project_id=None, plate_id=None
+    ):
         if archive_failure:
         if archive_failure:
             raise RuntimeError("archive copy failed")
             raise RuntimeError("archive copy failed")
 
 

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

@@ -653,6 +653,7 @@ export interface Archive {
   original_archive_id: number | null;  // ID of the first/original archive
   original_archive_id: number | null;  // ID of the first/original archive
   object_count: number | null;
   object_count: number | null;
   print_name: string | null;
   print_name: string | null;
+  plate_id: number | null;  // Selected plate of a multi-plate 3MF (#2603)
   print_time_seconds: number | null;
   print_time_seconds: number | null;
   actual_time_seconds: number | null;  // Computed from started_at/completed_at
   actual_time_seconds: number | null;  // Computed from started_at/completed_at
   time_accuracy: number | null;  // Percentage: 100 = perfect, >100 = faster than estimated
   time_accuracy: number | null;  // Percentage: 100 = perfect, >100 = faster than estimated

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

@@ -948,6 +948,7 @@ function ArchiveCard({
         <div className="flex items-center justify-between gap-2 mb-1">
         <div className="flex items-center justify-between gap-2 mb-1">
           <h3 className="min-w-0 font-medium text-white truncate">
           <h3 className="min-w-0 font-medium text-white truncate">
             {archive.print_name || archive.filename}
             {archive.print_name || archive.filename}
+            {archive.plate_id != null && ` — ${t('printers.plateNumber', { number: archive.plate_id })}`}
           </h3>
           </h3>
           <Button
           <Button
             variant="ghost"
             variant="ghost"

Filskillnaden har hållts tillbaka eftersom den är för stor
+ 0 - 0
static/assets/index-Bvvb3PBX.js


+ 1 - 1
static/index.html

@@ -26,7 +26,7 @@
 
 
     <!-- Splash screens for iOS -->
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-Byt13KYk.js"></script>
+    <script type="module" crossorigin src="/assets/index-Bvvb3PBX.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-CZwzTgpo.css">
     <link rel="stylesheet" crossorigin href="/assets/index-CZwzTgpo.css">
   </head>
   </head>
   <body>
   <body>

Vissa filer visades inte eftersom för många filer har ändrats