Procházet zdrojové kódy

fix(vp): Send All enqueues one item per plate; archive delete cascades to queue

  VP queue-mode multi-plate Send All
  ==========================================

  BambuStudio / OrcaSlicer "Send All" of a multi-plate project uploads ONE
  3MF containing every plate (one FTP STOR, single filename) — slice_info.config
  inside the file lists N <plate> blocks with their own index metadata and
  their own Metadata/plate_N.gcode payload. Pre-#1733 the VP queue path
  called _extract_plate_id which returned only the FIRST plate index, and
  _add_to_print_queue built exactly one PrintQueueItem from it. Plates 2..N
  silently dropped on the floor. From the user's perspective: Send All of a
  3-plate project produced 1 queue item, indistinguishable from a regular
  single-plate Send, with no log line to explain the discrepancy.

  The wire was confirmed against the live H2D-1 Proxy VP: the same file
  ships whether the user clicked Send or Send All; the only intent signal
  is the count of <plate> blocks inside slice_info.config.

  Fix: replaced _extract_plate_id (-> int | None) with _extract_plate_ids
  (-> list[int]). The list contains every <plate> block's index in order;
  falls back to [1] when slice_info.config is missing / unparseable so the
  single-plate case is preserved. _add_to_print_queue now loops over the
  list and creates one PrintQueueItem per plate, with:

    - plate-specific position = MAX(position) + iteration_number, so the
      items inherit consecutive positions and the slicer's plate order
      becomes the queue execution order.
    - per-plate required_filament_types / filament_overrides via
      extract_filament_requirements(file_path, plate_id) — the plate-aware
      filter shipped with #1697 — so the scheduler's per-printer "Any X"
      matching dispatches each plate onto a printer with the right
      colours loaded for THAT plate, not for plate 1's filament set.
    - shared archive_id across all plates (one upload = one archive row).
    - the VP's auto_dispatch + manual_start posture inherited unchanged.

  Net behaviour: single-plate Send hits the loop once → exactly today's
  result (one queue item, plate_id from the slicer, one archive). Multi-
  plate Send All of a 3-plate file → 3 queue items, plate_id 1/2/3,
  consecutive positions, all referencing the same backing archive.

  Archive delete cascades to queue rows
  =============================================

  Previously the soft-delete path (the default the trash-can button uses)
  called _cancel_pending_queue_items which only flipped queue rows with
  status='pending' to status='cancelled' while leaving every other status
  alone AND leaving every row in the DB. The Send All multi-plate work
  above made this much more visible: deleting an archive backed by N
  queue items now had to clean up N rows, and what users saw instead was
  N "cancelled" rows lingering in the queue history.

  Backend:
    - Replaced _cancel_pending_queue_items with _delete_related_queue_items
      (db, archive_id) -> int. DELETEs every queue row where
      archive_id = X regardless of status. Matches what the hard-delete
      path already did via the ON DELETE CASCADE FK on
      print_queue.archive_id — both paths now produce the same end state.
    - Print history lives in PrintLogEntry (FK ON DELETE SET NULL) and is
      untouched; Quick Stats / accuracy bands are preserved across both
      delete paths.
    - 409 guard on archives.py::delete_archive when any related queue
      item is currently status='printing'. Both soft and hard delete are
      gated; deleting the archive while a print is live would strip the
      dispatcher's metadata trail (filament / plate / ams_mapping) out
      from under the running print.
    - New GET /archives/{id}/delete-impact endpoint returns
      {related_queue_items: N, currently_printing: M}. Cheap, single
      endpoint, deliberately NOT folded into the archive list response
      so the much larger list endpoint isn't forced to run the same
      query per row.

  Frontend:
    - ArchivesPage delete-confirm modal queries the new endpoint when the
      modal opens (useQuery with enabled: showDeleteConfirm) and renders
      an amber "N queue items linked to this archive will also be removed"
      line when total > 0 AND printing = 0, OR a red "Cannot delete —
      M queue items are currently printing" line when printing > 0
      (confirm button disabled in that case so the user can't bonk the
      409 on submit).
    - ConfirmModal gained an optional confirmDisabled?: boolean prop —
      isLoading was the only disable knob before; this adds the external-
      precondition path.
    - 2 new i18n keys (deleteQueueItemsWarning, deleteBlockedByPrinting)
      translated across all 11 locales per feedback_translate_dont_fallback —
      no English fallbacks.

  No DB migration — the CASCADE FK was already in place; only the helper's
  semantics changed.
maziggy před 2 měsíci
rodič
revize
2cf6f29503

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
CHANGELOG.md


+ 51 - 1
backend/app/api/routes/archives.py

@@ -1479,6 +1479,35 @@ async def get_archive(
     return archive_to_response(archive, duplicates, run_aggregate=run_aggregates.get(archive.id))
 
 
+@router.get("/{archive_id}/delete-impact")
+async def get_archive_delete_impact(
+    archive_id: int,
+    db: AsyncSession = Depends(get_db),
+    auth_result: tuple[User | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.ARCHIVES_READ_ALL,
+            Permission.ARCHIVES_READ_OWN,
+        )
+    ),
+):
+    """Pre-flight for the delete-confirm modal (#1734).
+
+    Returns the number of related queue items the user is about to remove
+    AND whether any of them are currently printing (which would block the
+    delete with a 409 — surfaced to the modal so it can disable the
+    confirm button instead of failing on submit). Cheap, single endpoint —
+    not folded into the archive GET response so the much larger list
+    endpoint isn't forced to run the same query per row.
+    """
+    user, can_read_all = auth_result
+    service = ArchiveService(db)
+    archive = _ensure_archive_visible(await service.get_archive(archive_id), user, can_read_all)
+    from backend.app.services.archive import _count_related_queue_items
+
+    total, printing = await _count_related_queue_items(db, archive.id)
+    return {"related_queue_items": total, "currently_printing": printing}
+
+
 @router.get("/{archive_id}/runs", response_model=PrintLogResponse)
 async def list_archive_runs(
     archive_id: int,
@@ -1926,7 +1955,14 @@ async def delete_archive(
         )
     ),
 ):
-    """Delete an archive (soft by default; ``?purge_stats=true`` to hard-delete)."""
+    """Delete an archive (soft by default; ``?purge_stats=true`` to hard-delete).
+
+    Both delete paths now cascade to related ``print_queue`` rows (#1734) —
+    hard delete via the ``ON DELETE CASCADE`` FK, soft delete via the
+    ``_delete_related_queue_items`` helper. A 409 guard blocks the delete
+    when any related queue item is currently mid-print so the dispatcher
+    doesn't lose its metadata trail under the running print.
+    """
     user, can_modify_all = auth_result
 
     # Get archive first to check ownership
@@ -1940,6 +1976,20 @@ async def delete_archive(
         if archive.created_by_id != user.id:
             raise HTTPException(403, "You can only delete your own archives")
 
+    # #1734: block delete when any related queue item is currently printing.
+    # Both soft and hard delete are gated — an in-flight print needs its
+    # backing archive to stay around for the metadata trail (filament,
+    # plate, ams_mapping). The user can stop the print first, then retry.
+    from backend.app.services.archive import _count_related_queue_items
+
+    _related_total, related_printing = await _count_related_queue_items(db, archive_id)
+    if related_printing > 0:
+        raise HTTPException(
+            409,
+            f"Cannot delete archive — {related_printing} related queue item(s) are "
+            f"currently printing. Stop the print first, then retry.",
+        )
+
     service = ArchiveService(db)
     if purge_stats:
         # Hard-delete the linked PrintLogEntry rows first so their filament /

+ 55 - 19
backend/app/services/archive.py

@@ -914,28 +914,64 @@ async def _null_print_log_thumbnail_paths(db: AsyncSession, archive_id: int) ->
     await db.execute(sa_update(PrintLogEntry).where(PrintLogEntry.archive_id == archive_id).values(thumbnail_path=None))
 
 
-async def _cancel_pending_queue_items(db: AsyncSession, archive_id: int) -> None:
-    """Cancel pending queue items pointing at *archive_id* (#1348 follow-up).
-
-    Called from ``soft_delete_archive`` only — hard-delete is covered by the
-    ``ON DELETE CASCADE`` on ``print_queue.archive_id``.  A queue item
-    pointing at an archive whose 3MF has been removed from disk can never
-    actually dispatch, so cancelling at delete time both (a) tells the user
-    why the item disappeared from the pending list, and (b) stops the queue
-    page from 404-storming the archive thumbnail / plates / plate-thumbnail
-    endpoints when the row is rendered. Only ``pending`` items are touched;
-    ``printing`` is a rare race the printer-side fail-path catches, and
-    completed / failed / cancelled rows are historical and untouched.
+async def _delete_related_queue_items(db: AsyncSession, archive_id: int) -> int:
+    """Delete every queue item pointing at *archive_id* (#1734).
+
+    Called from ``soft_delete_archive``. Hard-delete is covered by the
+    ``ON DELETE CASCADE`` on ``print_queue.archive_id`` — same end state
+    via the FK. Pre-#1734 this helper merely flipped pending rows to
+    ``status='cancelled'`` while leaving every other status alone and
+    leaving the rows in the DB, which surprised users who expected the
+    queue lines to disappear when their backing archive went away. Worse,
+    a Send-All archive backed N queue items (one per plate, #1733) — soft-
+    deleting that archive left N "cancelled" rows behind, none of which
+    could ever dispatch.
+
+    Now we delete unconditionally regardless of status. ``printing`` rows
+    are blocked one layer up at the route (``delete_archive`` returns 409
+    when a related row is mid-print) so we never delete an actively-
+    running queue row out from under the dispatcher. Completed / failed
+    / cancelled rows go too — they're queue history, not print history.
+    PrintLogEntry rows are the authoritative print history and are
+    untouched (FK ``ON DELETE SET NULL``).
+
+    Returns the number of rows removed so the caller can report it.
     """
-    from sqlalchemy import update as sa_update
+    from sqlalchemy import delete as sa_delete
+
+    from backend.app.models.print_queue import PrintQueueItem
+
+    result = await db.execute(sa_delete(PrintQueueItem).where(PrintQueueItem.archive_id == archive_id))
+    return result.rowcount or 0
+
+
+async def _count_related_queue_items(db: AsyncSession, archive_id: int) -> tuple[int, int]:
+    """Return ``(total, printing)`` queue items linked to *archive_id*.
+
+    Used by the archive GET response so the frontend delete-confirm modal
+    can surface how much the deletion will wipe out, and by the delete
+    route so it can 409 when a related row is currently printing (#1734).
+    """
+    from sqlalchemy import func as sa_func, select as sa_select
 
     from backend.app.models.print_queue import PrintQueueItem
 
-    await db.execute(
-        sa_update(PrintQueueItem)
-        .where(PrintQueueItem.archive_id == archive_id, PrintQueueItem.status == "pending")
-        .values(status="cancelled", waiting_reason="Source archive deleted")
-    )
+    total = (
+        await db.execute(
+            sa_select(sa_func.count()).select_from(PrintQueueItem).where(PrintQueueItem.archive_id == archive_id)
+        )
+    ).scalar_one()
+    printing = (
+        await db.execute(
+            sa_select(sa_func.count())
+            .select_from(PrintQueueItem)
+            .where(
+                PrintQueueItem.archive_id == archive_id,
+                PrintQueueItem.status == "printing",
+            )
+        )
+    ).scalar_one()
+    return int(total or 0), int(printing or 0)
 
 
 class ArchiveService:
@@ -1375,7 +1411,7 @@ class ArchiveService:
         dir_to_delete = self._resolve_archive_dir_for_delete(archive)
 
         await _null_print_log_thumbnail_paths(self.db, archive_id)
-        await _cancel_pending_queue_items(self.db, archive_id)
+        await _delete_related_queue_items(self.db, archive_id)
         archive.deleted_at = datetime.now(timezone.utc)
         await self.db.commit()
 

+ 112 - 70
backend/app/services/virtual_printer/manager.py

@@ -588,44 +588,21 @@ class VirtualPrinterInstance:
                     target_model = None
                     if not self.target_printer_id and self.model:
                         target_model = VIRTUAL_PRINTER_MODELS.get(self.model)
-                    plate_id = self._extract_plate_id(file_path)
-
-                    # Parse the 3MF for per-slot filament requirements (#1188).
-                    # The manual /print-queue/ POST flow does this at queue-add
-                    # time; the VP path used to skip it, so the scheduler fell
-                    # through to model-only matching and dispatched onto whatever
-                    # printer happened to be free regardless of loaded colour.
-                    # required_filament_types is populated unconditionally — it's
-                    # cheap, lets the scheduler reject obvious mis-matches even
-                    # without force_color_match. filament_overrides only carries
-                    # force_color_match=True when the per-VP setting is on, so
-                    # upgraders keep the old behaviour by default.
-                    required_filament_types_json: str | None = None
-                    filament_overrides_json: str | None = None
-                    requirements = extract_filament_requirements(file_path, plate_id)
-                    if requirements:
-                        types = sorted({r["type"] for r in requirements if r.get("type")})
-                        if types:
-                            required_filament_types_json = json.dumps(types)
-                        if self.queue_force_color_match:
-                            overrides = [
-                                {
-                                    "slot_id": r["slot_id"],
-                                    "type": r.get("type", ""),
-                                    "color": r.get("color", ""),
-                                    "force_color_match": True,
-                                }
-                                for r in requirements
-                                if r.get("type") and r.get("color")
-                            ]
-                            if overrides:
-                                filament_overrides_json = json.dumps(overrides)
-
-                    # Pick the next free position the same way the manual
-                    # /print-queue/ POST does — previously hardcoded to 1,
-                    # which created duplicate position=1 rows on every
-                    # VP upload and made queue execution order
-                    # non-deterministic for any non-empty queue.
+                    # #1733: multi-plate "Send All" uploads ship every plate in
+                    # one 3MF — `slice_info.config` lists each `<plate>` with
+                    # its own index. Enqueue one PrintQueueItem per plate so
+                    # the scheduler runs each separately. Single-plate "Send"
+                    # comes through as `[N]` (one plate index) so the loop
+                    # below runs once and the existing behaviour is preserved.
+                    plate_ids = self._extract_plate_ids(file_path)
+
+                    # Pick a base position the same way the manual /print-queue/
+                    # POST does, then hand consecutive positions to each plate
+                    # so a Send All keeps plate-order execution inside the
+                    # queue (#1733). Previously hardcoded to 1, which created
+                    # duplicate position=1 rows on every VP upload and made
+                    # queue execution order non-deterministic for any non-
+                    # empty queue.
                     from sqlalchemy import func, select as _sql_select
 
                     queue_scope = _sql_select(func.max(PrintQueueItem.position)).where(
@@ -640,27 +617,72 @@ class VirtualPrinterInstance:
                         max_pos = int(max_pos_raw) if max_pos_raw is not None else 0
                     except (TypeError, ValueError):
                         max_pos = 0
-                    next_position = max_pos + 1
-
-                    queue_item = PrintQueueItem(
-                        printer_id=self.target_printer_id,
-                        target_model=target_model,
-                        archive_id=archive.id,
-                        plate_id=plate_id,
-                        position=next_position,
-                        status="pending",
-                        manual_start=not self.auto_dispatch,
-                        required_filament_types=required_filament_types_json,
-                        filament_overrides=filament_overrides_json,
-                        bed_levelling=bed_levelling,
-                        flow_cali=flow_cali,
-                        vibration_cali=vibration_cali,
-                        layer_inspect=layer_inspect,
-                        timelapse=timelapse,
-                    )
-                    db.add(queue_item)
+
+                    # Parse per-plate filament requirements (#1188). Each plate
+                    # has its own filament set in `slice_info.config`, so the
+                    # `required_filament_types` / `filament_overrides` columns
+                    # on each queue item reflect THAT plate, not the file's
+                    # first plate. Scoping was already plate-aware via #1697 —
+                    # the `extract_filament_requirements(path, plate_id)` filter
+                    # returns just the plate's filaments. required_filament_types
+                    # is populated unconditionally — it's cheap, lets the
+                    # scheduler reject obvious mis-matches even without
+                    # force_color_match. filament_overrides only carries
+                    # force_color_match=True when the per-VP setting is on, so
+                    # upgraders keep the old behaviour by default.
+                    queue_item_ids: list[int] = []
+                    for offset, plate_id in enumerate(plate_ids, start=1):
+                        required_filament_types_json: str | None = None
+                        filament_overrides_json: str | None = None
+                        requirements = extract_filament_requirements(file_path, plate_id)
+                        if requirements:
+                            types = sorted({r["type"] for r in requirements if r.get("type")})
+                            if types:
+                                required_filament_types_json = json.dumps(types)
+                            if self.queue_force_color_match:
+                                overrides = [
+                                    {
+                                        "slot_id": r["slot_id"],
+                                        "type": r.get("type", ""),
+                                        "color": r.get("color", ""),
+                                        "force_color_match": True,
+                                    }
+                                    for r in requirements
+                                    if r.get("type") and r.get("color")
+                                ]
+                                if overrides:
+                                    filament_overrides_json = json.dumps(overrides)
+
+                        queue_item = PrintQueueItem(
+                            printer_id=self.target_printer_id,
+                            target_model=target_model,
+                            archive_id=archive.id,
+                            plate_id=plate_id,
+                            position=max_pos + offset,
+                            status="pending",
+                            manual_start=not self.auto_dispatch,
+                            required_filament_types=required_filament_types_json,
+                            filament_overrides=filament_overrides_json,
+                            bed_levelling=bed_levelling,
+                            flow_cali=flow_cali,
+                            vibration_cali=vibration_cali,
+                            layer_inspect=layer_inspect,
+                            timelapse=timelapse,
+                        )
+                        db.add(queue_item)
+                        await db.flush()  # populate queue_item.id before logging
+                        queue_item_ids.append(queue_item.id)
                     await db.commit()
-                    logger.info("[VP %s] Added to queue: %s", self.name, queue_item.id)
+                    if len(queue_item_ids) == 1:
+                        logger.info("[VP %s] Added to queue: %s", self.name, queue_item_ids[0])
+                    else:
+                        logger.info(
+                            "[VP %s] Added %d queue items for multi-plate upload (plates %s): %s",
+                            self.name,
+                            len(queue_item_ids),
+                            plate_ids,
+                            queue_item_ids,
+                        )
                     await self._broadcast_archive_created(archive)
                 else:
                     logger.error("Failed to archive file: %s", file_path.name)
@@ -700,8 +722,27 @@ class VirtualPrinterInstance:
             logger.debug("[VP %s] archive_created broadcast failed: %s", self.name, e)
 
     @staticmethod
-    def _extract_plate_id(file_path: Path) -> int | None:
-        """Extract plate index from 3MF slice_info.config."""
+    def _extract_plate_ids(file_path: Path) -> list[int]:
+        """Extract every plate index from a 3MF's slice_info.config.
+
+        A multi-plate "Send All" from BambuStudio / OrcaSlicer uploads a
+        single 3MF containing every plate the user selected. Each plate
+        has its own ``<plate>`` block with a ``<metadata key="index"
+        value="N"/>`` child and its own ``Metadata/plate_N.gcode`` payload
+        inside the same zip. Returning the full ordered list lets the VP
+        queue path create one queue item per plate (`_add_to_print_queue`
+        loops over the result), so "Send All" of a 3-plate file produces
+        3 queue items sharing the same archive — one per plate to print.
+
+        Single-plate "Send" hits the same code path and returns ``[N]``
+        for whichever plate the user selected; the loop runs once and the
+        existing single-plate behaviour is preserved.
+
+        Returns ``[1]`` when the 3MF is missing ``slice_info.config``,
+        unparseable, or contains no plate-index metadata — the original
+        single-plate fallback. Production logs at debug so a non-3MF
+        upload doesn't spam, but the trail survives for support bundles.
+        """
         try:
             import xml.etree.ElementTree as ET
             import zipfile
@@ -710,19 +751,20 @@ class VirtualPrinterInstance:
                 if "Metadata/slice_info.config" in zf.namelist():
                     content = zf.read("Metadata/slice_info.config").decode()
                     root = ET.fromstring(content)  # noqa: S314  # nosec B314
-                    plate = root.find(".//plate")
-                    if plate is not None:
+                    plate_ids: list[int] = []
+                    for plate in root.findall(".//plate"):
                         for meta in plate.findall("metadata"):
                             if meta.get("key") == "index" and meta.get("value"):
-                                return int(meta.get("value"))
+                                try:
+                                    plate_ids.append(int(meta.get("value")))
+                                except ValueError:
+                                    continue
+                                break
+                    if plate_ids:
+                        return plate_ids
         except Exception as e:
-            # Malformed / missing slice_info.config — fall through to None.
-            # Logged at debug so a non-3MF or unconventional 3MF doesn't
-            # spam production logs; a debug trail exists for support
-            # bundles when wrong-plate dispatches are reported.
-            logger.debug("[VP] _extract_plate_id failed for %s: %s", file_path.name, e)
-            return None
-        return None
+            logger.debug("[VP] _extract_plate_ids failed for %s: %s", file_path.name, e)
+        return [1]
 
     # -- Service lifecycle --
 

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

@@ -294,6 +294,64 @@ class TestArchivesAPI:
 
         assert response.status_code == 404
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_delete_archive_blocked_when_related_queue_item_printing(
+        self, async_client: AsyncClient, archive_factory, printer_factory, db_session
+    ):
+        """#1734: archive delete must 409 when a related queue item is currently
+        mid-print — deleting the archive would strip the dispatcher's metadata
+        trail (filament / plate / ams_mapping) out from under the running print.
+        Both soft and hard delete are gated by the same precondition.
+        """
+        from backend.app.models.print_queue import PrintQueueItem
+
+        printer = await printer_factory()
+        archive = await archive_factory(printer.id)
+        db_session.add(PrintQueueItem(printer_id=printer.id, archive_id=archive.id, status="printing", position=1))
+        await db_session.commit()
+
+        soft = await async_client.delete(f"/api/v1/archives/{archive.id}")
+        assert soft.status_code == 409
+        assert "printing" in soft.json()["detail"].lower()
+
+        hard = await async_client.delete(f"/api/v1/archives/{archive.id}?purge_stats=true")
+        assert hard.status_code == 409
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_archive_delete_impact_reports_counts(
+        self, async_client: AsyncClient, archive_factory, printer_factory, db_session
+    ):
+        """#1734: the delete-impact pre-flight endpoint reports the total
+        number of related queue items AND how many are currently printing,
+        so the frontend can both warn the user before they confirm AND
+        disable the confirm button when the printing count is non-zero.
+        """
+        from backend.app.models.print_queue import PrintQueueItem
+
+        printer = await printer_factory()
+        archive = await archive_factory(printer.id)
+        # Build a mixed-status set the way a Send All upload + later in-flight
+        # dispatch looks at the wire (#1733).
+        db_session.add_all(
+            [
+                PrintQueueItem(printer_id=printer.id, archive_id=archive.id, status="pending", position=1),
+                PrintQueueItem(printer_id=printer.id, archive_id=archive.id, status="pending", position=2),
+                PrintQueueItem(printer_id=printer.id, archive_id=archive.id, status="printing", position=3),
+            ]
+        )
+        # An unrelated archive's queue rows must not bleed into the count.
+        other = await archive_factory(printer.id)
+        db_session.add(PrintQueueItem(printer_id=printer.id, archive_id=other.id, status="pending", position=4))
+        await db_session.commit()
+
+        resp = await async_client.get(f"/api/v1/archives/{archive.id}/delete-impact")
+        assert resp.status_code == 200
+        body = resp.json()
+        assert body["related_queue_items"] == 3
+        assert body["currently_printing"] == 1
+
     @pytest.mark.asyncio
     @pytest.mark.integration
     async def test_soft_delete_preserves_stats_contribution(

+ 29 - 11
backend/tests/integration/test_print_queue_api.py

@@ -2008,13 +2008,25 @@ class TestAbortedStatusNormalisation:
 
     @pytest.mark.asyncio
     @pytest.mark.integration
-    async def test_soft_delete_archive_cancels_pending_queue_items(
+    async def test_soft_delete_archive_deletes_all_related_queue_items(
         self, async_client: AsyncClient, printer_factory, archive_factory, queue_item_factory, db_session
     ):
-        """Soft-deleting an archive cancels its pending queue items with a
-        clear reason. The 3MF is gone from disk so the item can never
-        dispatch — leaving it in 'pending' would 404-storm the queue page
-        and confuse the user about why nothing prints."""
+        """Soft-deleting an archive removes every related queue item, regardless
+        of status (#1734). Pre-#1734 only ``pending`` rows were flipped to
+        ``cancelled`` and stayed in the DB, surprising users who expected the
+        queue lines to disappear with the archive — especially on multi-plate
+        Send All uploads (#1733), where ONE archive backed N queue items and
+        soft-deleting the archive left N "cancelled" rows behind. The change
+        keeps the printing guard (a row with ``status='printing'`` blocks the
+        delete one layer up at the API route), so we never delete the row of
+        an actively-running print here.
+
+        Print history lives in ``PrintLogEntry`` (FK ``ON DELETE SET NULL``) —
+        the audit trail survives independently of the queue rows.
+        """
+        from sqlalchemy import select
+
+        from backend.app.models.print_queue import PrintQueueItem
         from backend.app.services.archive import ArchiveService
 
         printer = await printer_factory()
@@ -2025,12 +2037,18 @@ class TestAbortedStatusNormalisation:
         service = ArchiveService(db_session)
         assert await service.soft_delete_archive(archive.id) is True
 
-        await db_session.refresh(pending)
-        await db_session.refresh(completed)
-        assert pending.status == "cancelled"
-        assert pending.waiting_reason == "Source archive deleted"
-        # Historical rows untouched — they're audit-trail.
-        assert completed.status == "completed"
+        # Every queue row that referenced this archive is gone — both the
+        # pending and the completed rows. Print history (PrintLogEntry) is
+        # the authoritative record and is preserved by the FK SET NULL.
+        remaining = (
+            (await db_session.execute(select(PrintQueueItem).where(PrintQueueItem.id.in_([pending.id, completed.id]))))
+            .scalars()
+            .all()
+        )
+        assert remaining == [], (
+            "Soft-deleting the archive must delete every related queue row, "
+            f"got {[(r.id, r.status) for r in remaining]} still present"
+        )
 
     @pytest.mark.asyncio
     @pytest.mark.integration

+ 134 - 0
backend/tests/unit/services/test_virtual_printer.py

@@ -1342,6 +1342,140 @@ class TestVirtualPrinterInstance:
         # Position = max(7) + 1 = 8 — NOT the legacy hardcoded 1.
         assert queue_item.position == 8
 
+    @pytest.mark.asyncio
+    async def test_add_to_print_queue_multi_plate_send_all_enqueues_one_per_plate(self, tmp_path):
+        """#1733: BambuStudio / OrcaSlicer "Send All" of a multi-plate project
+        uploads ONE 3MF containing every plate. Pre-fix only the first plate
+        index was extracted and one queue item was created; plates 2..N were
+        silently dropped. Post-fix every `<plate>` block in `slice_info.config`
+        produces its own PrintQueueItem with the correct ``plate_id``, sharing
+        the same backing archive, with consecutive positions for plate-order
+        execution.
+        """
+        from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
+
+        added_items: list = []
+
+        class _RecordingDb:
+            def __init__(self):
+                # Capture inserted items as they're added; assign a fake .id
+                # on flush so the manager's logger doesn't see None.
+                self._next_id = 1000
+
+                def _add(item):
+                    added_items.append(item)
+
+                self.add = _add
+                self.commit = AsyncMock()
+
+            async def execute(self, query):  # noqa: ARG002
+                """Return MAX(position) = 0 so plate items land at 1, 2, 3."""
+                result = MagicMock()
+                result.scalar = MagicMock(return_value=0)
+                return result
+
+            async def flush(self):
+                # Mimic the FK populate so queue_item.id is available after add().
+                for item in added_items:
+                    if getattr(item, "id", None) is None:
+                        item.id = self._next_id
+                        self._next_id += 1
+
+        mock_db = _RecordingDb()
+        mock_session_factory = MagicMock()
+        mock_session_ctx = AsyncMock()
+        mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
+        mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
+        mock_session_factory.return_value = mock_session_ctx
+
+        inst = VirtualPrinterInstance(
+            vp_id=44,
+            name="MultiPlateSendAll",
+            mode="queue",
+            model="O1D",  # H2D — matches the live VP H2D-1 Proxy in #1733
+            access_code="12345678",
+            serial_suffix="391800044",
+            target_printer_id=1,
+            auto_dispatch=False,  # manual_start, mirrors the live VP
+            base_dir=tmp_path,
+            session_factory=mock_session_factory,
+        )
+
+        # Build a 3MF with three plates baked into slice_info.config —
+        # mirrors what BambuStudio / OrcaSlicer's "Send All" puts on the wire.
+        file_path = tmp_path / "Cube.gcode.3mf"
+        _write_3mf_with_filaments(
+            file_path, [{"id": 1, "type": "PLA", "color": "#000000", "used_g": "15.61"}], plate_index=1
+        )
+        # Append plate 2 and 3 blocks to slice_info.config to mimic Send All.
+        with zipfile.ZipFile(file_path, "r") as zf:
+            existing = zf.read("Metadata/slice_info.config").decode()
+        # Inject two additional <plate> blocks (indices 2 and 3) inside <config>.
+        multi_plate_config = existing.replace(
+            "</config>",
+            (
+                '<plate><metadata key="index" value="2"/>'
+                '<filament id="2" type="PETG" color="#FB0207" used_g="14.45"/>'
+                "</plate>"
+                '<plate><metadata key="index" value="3"/>'
+                '<filament id="3" type="PLA" color="#FFFFFF" used_g="12.10"/>'
+                "</plate>"
+                "</config>"
+            ),
+        )
+        # Repack the zip with the expanded slice_info.config.
+        import io as _io
+
+        buf = _io.BytesIO()
+        with zipfile.ZipFile(file_path, "r") as src, zipfile.ZipFile(buf, "w") as dst:
+            for name in src.namelist():
+                if name == "Metadata/slice_info.config":
+                    dst.writestr(name, multi_plate_config)
+                else:
+                    dst.writestr(name, src.read(name))
+            # Plate-2 and plate-3 gcode payloads so `extract_filament_requirements`
+            # has something to read for each — contents irrelevant, presence matters.
+            dst.writestr("Metadata/plate_2.gcode", "; plate 2 gcode\n")
+            dst.writestr("Metadata/plate_3.gcode", "; plate 3 gcode\n")
+        file_path.write_bytes(buf.getvalue())
+
+        mock_archive = MagicMock()
+        mock_archive.id = 999
+        mock_archive.printer_id = None
+        mock_archive.filename = "Cube.gcode.3mf"
+        mock_archive.print_name = "Cube"
+        mock_archive.status = "archived"
+
+        with (
+            patch(
+                "backend.app.api.routes.settings.get_setting",
+                new_callable=AsyncMock,
+                return_value=None,
+            ),
+            patch(
+                "backend.app.services.archive.ArchiveService.archive_print",
+                new_callable=AsyncMock,
+                return_value=mock_archive,
+            ),
+            patch(
+                "backend.app.core.websocket.ws_manager.send_archive_created",
+                new_callable=AsyncMock,
+            ),
+        ):
+            await inst._add_to_print_queue(file_path, "192.168.1.100")
+
+        # Three queue items, one per plate, with the correct plate_id and
+        # consecutive positions starting at MAX(position)+1 = 1.
+        assert len(added_items) == 3, f"Expected 3 queue items for 3-plate Send All, got {len(added_items)}"
+        plate_ids = [q.plate_id for q in added_items]
+        assert plate_ids == [1, 2, 3], f"plate_ids should preserve slice_info order, got {plate_ids}"
+        positions = [q.position for q in added_items]
+        assert positions == [1, 2, 3], f"positions should be consecutive, got {positions}"
+        archive_ids = {q.archive_id for q in added_items}
+        assert archive_ids == {999}, f"All queue items must share the single backing archive, got {archive_ids}"
+        # auto_dispatch=False on the VP → every item is manual_start.
+        assert all(q.manual_start for q in added_items)
+
 
 class TestVirtualPrinterManager:
     """Tests for VirtualPrinterManager orchestrator."""

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

@@ -3729,6 +3729,15 @@ export const api = {
   },
   getArchive: (id: number) => request<Archive>(`/archives/${id}`),
   getArchiveRuns: (id: number) => request<PrintLogResponse>(`/archives/${id}/runs`),
+  /**
+   * Pre-flight for the delete-confirm modal (#1734). Returns the number of
+   * related queue items that will be removed along with the archive AND how
+   * many are currently printing (server 409s on delete if > 0).
+   */
+  getArchiveDeleteImpact: (id: number) =>
+    request<{ related_queue_items: number; currently_printing: number }>(
+      `/archives/${id}/delete-impact`
+    ),
   searchArchives: (query: string, options?: {
     printerId?: number;
     projectId?: number;

+ 7 - 1
frontend/src/components/ConfirmModal.tsx

@@ -19,6 +19,11 @@ interface ConfirmModalProps {
   variant?: 'danger' | 'warning' | 'default';
   isLoading?: boolean;
   loadingText?: string;
+  // Disable the confirm button without a loading spinner. Used when an
+  // external precondition forbids the action (e.g. #1734 — a related queue
+  // item is mid-print, so the archive delete must be blocked at the UI
+  // layer too even though the backend will 409 anyway).
+  confirmDisabled?: boolean;
   // Optional extra content rendered between the message and the buttons —
   // used for opt-in checkboxes (e.g. the "Also remove from statistics"
   // toggle in the archive delete confirmation, #1343).
@@ -38,6 +43,7 @@ export function ConfirmModal({
   variant = 'default',
   isLoading = false,
   loadingText,
+  confirmDisabled = false,
   children,
   onConfirm,
   onCancel,
@@ -104,7 +110,7 @@ export function ConfirmModal({
             <Button
               onClick={onConfirm}
               className={`flex-1 ${styles.button}`}
-              disabled={isLoading}
+              disabled={isLoading || confirmDisabled}
             >
               {isLoading ? (
                 <>

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

@@ -864,6 +864,8 @@ export default {
       deleteConfirm: 'Möchten Sie "{{name}}" wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.',
       deleteButton: 'Löschen',
       deletePurgeStats: 'Diesen Druck auch aus den Quick Stats entfernen (Filament, Zeit, Kosten, Energie)',
+      deleteQueueItemsWarning: '{{count}} mit diesem Archiv verknüpfte Warteschlangeneinträge werden ebenfalls entfernt.',
+      deleteBlockedByPrinting: 'Löschen nicht möglich — {{count}} Warteschlangeneinträge werden derzeit gedruckt. Druck zuerst stoppen und erneut versuchen.',
       removeSource3mf: 'Quell-3MF entfernen',
       removeSource3mfConfirm: 'Möchten Sie die Quell-3MF-Datei wirklich von "{{name}}" entfernen? Die ursprüngliche Slicer-Projektdatei wird gelöscht.',
       removeButton: 'Entfernen',

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

@@ -864,6 +864,8 @@ export default {
       deleteConfirm: 'Are you sure you want to delete "{{name}}"? This action cannot be undone.',
       deleteButton: 'Delete',
       deletePurgeStats: 'Also remove this print from Quick Stats (filament, time, cost, energy)',
+      deleteQueueItemsWarning: '{{count}} queue item(s) linked to this archive will also be removed.',
+      deleteBlockedByPrinting: 'Cannot delete — {{count}} queue item(s) are currently printing. Stop the print first, then retry.',
       removeSource3mf: 'Remove Source 3MF',
       removeSource3mfConfirm: 'Are you sure you want to remove the source 3MF file from "{{name}}"? This will delete the original slicer project file.',
       removeButton: 'Remove',

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

@@ -864,6 +864,8 @@ export default {
       deleteConfirm: '¿Está seguro de que desea eliminar "{{name}}"? Esta acción no se puede deshacer.',
       deleteButton: 'Eliminar',
       deletePurgeStats: 'Eliminar también esta impresión de las estadísticas rápidas (filamento, tiempo, coste, energía)',
+      deleteQueueItemsWarning: 'También se eliminarán {{count}} elemento(s) de la cola vinculado(s) a este archivo.',
+      deleteBlockedByPrinting: 'No se puede eliminar — {{count}} elemento(s) de la cola se están imprimiendo. Detén la impresión primero y vuelve a intentarlo.',
       removeSource3mf: 'Eliminar 3MF de origen',
       removeSource3mfConfirm: '¿Está seguro de que desea eliminar el archivo 3MF de origen de "{{name}}"? Esto eliminará el archivo de proyecto original del laminador.',
       removeButton: 'Eliminar',

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

@@ -864,6 +864,8 @@ export default {
       deleteConfirm: 'Supprimer "{{name}}" ? Cette action est irréversible.',
       deleteButton: 'Supprimer',
       deletePurgeStats: 'Retirer également cette impression des Quick Stats (filament, temps, coût, énergie)',
+      deleteQueueItemsWarning: '{{count}} élément(s) de file d\'attente lié(s) à cette archive seront également supprimé(s).',
+      deleteBlockedByPrinting: 'Suppression impossible — {{count}} élément(s) de file d\'attente en cours d\'impression. Arrêtez l\'impression d\'abord, puis réessayez.',
       removeSource3mf: 'Retirer Source 3MF',
       removeSource3mfConfirm: 'Retirer le fichier 3MF de "{{name}}" ?',
       removeButton: 'Retirer',

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

@@ -864,6 +864,8 @@ export default {
       deleteConfirm: 'Sei sicuro di eliminare "{{name}}"? Questa azione non può essere annullata.',
       deleteButton: 'Elimina',
       deletePurgeStats: 'Rimuovi anche questa stampa dalle Quick Stats (filamento, tempo, costo, energia)',
+      deleteQueueItemsWarning: '{{count}} elemento/i in coda collegato/i a questo archivio verrà/verranno rimosso/i.',
+      deleteBlockedByPrinting: 'Impossibile eliminare — {{count}} elemento/i in coda sono attualmente in stampa. Interrompi la stampa, poi riprova.',
       removeSource3mf: 'Rimuovi Sorgente 3MF',
       removeSource3mfConfirm: 'Sei sicuro di rimuovere il file sorgente 3MF da "{{name}}"? Questo eliminerà il progetto slicer originale.',
       removeButton: 'Rimuovi',

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

@@ -863,6 +863,8 @@ export default {
       deleteConfirm: '"{{name}}" を削除しますか?この操作は取り消せません。',
       deleteButton: '削除',
       deletePurgeStats: 'このプリントをQuick Statsからも削除(フィラメント、時間、コスト、電力)',
+      deleteQueueItemsWarning: 'このアーカイブにリンクされた {{count}} 件のキュー項目も削除されます。',
+      deleteBlockedByPrinting: '削除できません — {{count}} 件のキュー項目が現在印刷中です。先に印刷を停止してから再試行してください。',
       removeSource3mf: 'ソース3MFを削除',
       removeSource3mfConfirm: '"{{name}}"からソース3MFファイルを削除してもよろしいですか?元のスライサープロジェクトファイルが削除されます。',
       removeButton: '削除',

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

@@ -812,7 +812,9 @@ export default {
       deleteArchives: '아카이브 삭제',
       deleteArchivesConfirm: '{{count}}개 아카이브를 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.',
       deleteCount: '{{count}}개 삭제',
-      deletePurgeStats: '빠른 통계에서도 이 인쇄 항목 제거 (필라멘트, 시간, 비용, 에너지)'
+      deletePurgeStats: '빠른 통계에서도 이 인쇄 항목 제거 (필라멘트, 시간, 비용, 에너지)',
+      deleteQueueItemsWarning: '이 아카이브와 연결된 {{count}}개의 대기열 항목도 함께 제거됩니다.',
+      deleteBlockedByPrinting: '삭제할 수 없습니다 — {{count}}개의 대기열 항목이 현재 인쇄 중입니다. 인쇄를 먼저 중지한 다음 다시 시도하십시오.'
     },
     page: {
       title: '아카이브',

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

@@ -864,6 +864,8 @@ export default {
       deleteConfirm: 'Tem certeza de que deseja excluir "{{name}}"? Esta ação não pode ser desfeita.',
       deleteButton: 'Excluir',
       deletePurgeStats: 'Remover também esta impressão das Quick Stats (filamento, tempo, custo, energia)',
+      deleteQueueItemsWarning: '{{count}} item(ns) de fila vinculado(s) a este arquivo também serão removidos.',
+      deleteBlockedByPrinting: 'Não é possível excluir — {{count}} item(ns) de fila estão imprimindo agora. Pare a impressão primeiro e tente novamente.',
       removeSource3mf: 'Remover Source 3MF',
       removeSource3mfConfirm: 'Tem certeza de que deseja remover o arquivo source 3MF de "{{name}}"? Isso excluirá o arquivo original do projeto do fatiador.',
       removeButton: 'Remover',

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

@@ -864,6 +864,8 @@ export default {
       deleteConfirm: '"{{name}}" silmek istediğinizden emin misiniz? Bu işlem geri alınamaz.',
       deleteButton: 'Sil',
       deletePurgeStats: 'Ayrıca bu baskıyı Hızlı İstatistiklerden de kaldır (filament, süre, maliyet, enerji)',
+      deleteQueueItemsWarning: 'Bu arşive bağlı {{count}} kuyruk öğesi de kaldırılacak.',
+      deleteBlockedByPrinting: 'Silinemiyor — {{count}} kuyruk öğesi şu anda yazdırılıyor. Önce yazdırmayı durdurun, ardından tekrar deneyin.',
       removeSource3mf: 'Kaynak 3MF Kaldır',
       removeSource3mfConfirm: '"{{name}}" dosyasından kaynak 3MF dosyasını kaldırmak istediğinizden emin misiniz? Bu, orijinal dilimleyici proje dosyasını silecek.',
       removeButton: 'Kaldır',

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

@@ -864,6 +864,8 @@ export default {
       deleteConfirm: '确定要删除"{{name}}"吗?此操作无法撤销。',
       deleteButton: '删除',
       deletePurgeStats: '同时从快速统计中删除此打印(耗材、时间、成本、能耗)',
+      deleteQueueItemsWarning: '与此归档关联的 {{count}} 个队列项也将被移除。',
+      deleteBlockedByPrinting: '无法删除 — {{count}} 个队列项正在打印。请先停止打印再重试。',
       removeSource3mf: '移除源 3MF',
       removeSource3mfConfirm: '确定要从"{{name}}"中移除源 3MF 文件吗?这将删除原始切片项目文件。',
       removeButton: '移除',

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

@@ -864,6 +864,8 @@ export default {
       deleteConfirm: '確定要刪除"{{name}}"嗎?此操作無法復原。',
       deleteButton: '刪除',
       deletePurgeStats: '同時從快速統計中刪除此列印(耗材、時間、成本、能耗)',
+      deleteQueueItemsWarning: '與此封存連結的 {{count}} 個佇列項目也將被移除。',
+      deleteBlockedByPrinting: '無法刪除 — {{count}} 個佇列項目正在列印。請先停止列印再重試。',
       removeSource3mf: '移除源 3MF',
       removeSource3mfConfirm: '確定要從"{{name}}"中移除源 3MF 檔案嗎?這將刪除原始切片專案檔案。',
       removeButton: '移除',

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

@@ -186,6 +186,15 @@ function ArchiveCard({
   // #1343: when true, the delete also drops the row from Quick Stats. Default
   // off — soft delete preserves the archive's filament/time/cost contribution.
   const [deletePurgeStats, setDeletePurgeStats] = useState(false);
+  // #1734: pre-flight count of related queue items so the confirm modal can
+  // tell the user how many will be removed and disable the button if any are
+  // currently printing (the server 409s in that case).
+  const deleteImpactQuery = useQuery({
+    queryKey: ['archive', archive.id, 'delete-impact'],
+    queryFn: () => api.getArchiveDeleteImpact(archive.id),
+    enabled: showDeleteConfirm,
+    staleTime: 0,
+  });
   const [showEdit, setShowEdit] = useState(false);
   const [showPrintLog, setShowPrintLog] = useState(false);
   const [showTimelapse, setShowTimelapse] = useState(false);
@@ -1287,6 +1296,7 @@ function ArchiveCard({
           message={t('archives.modal.deleteConfirm', { name: archive.print_name || archive.filename })}
           confirmText={t('archives.modal.deleteButton')}
           variant="danger"
+          confirmDisabled={(deleteImpactQuery.data?.currently_printing ?? 0) > 0}
           onConfirm={() => {
             deleteMutation.mutate(deletePurgeStats);
             setShowDeleteConfirm(false);
@@ -1297,6 +1307,25 @@ function ArchiveCard({
             setDeletePurgeStats(false);
           }}
         >
+          {/* #1734: warn the user when related queue items will also be removed,
+              and block the action entirely if any are currently printing. */}
+          {(deleteImpactQuery.data?.related_queue_items ?? 0) > 0 && (
+            <div
+              className={
+                (deleteImpactQuery.data?.currently_printing ?? 0) > 0
+                  ? 'text-sm text-red-400 mb-2'
+                  : 'text-sm text-amber-400 mb-2'
+              }
+            >
+              {(deleteImpactQuery.data?.currently_printing ?? 0) > 0
+                ? t('archives.modal.deleteBlockedByPrinting', {
+                    count: deleteImpactQuery.data!.currently_printing,
+                  })
+                : t('archives.modal.deleteQueueItemsWarning', {
+                    count: deleteImpactQuery.data!.related_queue_items,
+                  })}
+            </div>
+          )}
           {/* #1343: opt-in checkbox — by default the archive is soft-deleted,
               so its filament / time / cost contribution stays in Quick Stats. */}
           <label className="flex items-start gap-2 cursor-pointer text-sm text-bambu-gray">
@@ -1563,6 +1592,14 @@ function ArchiveListRow({
   // #1343: opt-in "Also remove from statistics" checkbox state. Default off
   // — soft delete keeps the archive's contribution to Quick Stats.
   const [deletePurgeStats, setDeletePurgeStats] = useState(false);
+  // #1734: pre-flight count of related queue items for the delete modal.
+  // Same shape as the card-view sibling above.
+  const deleteImpactQuery = useQuery({
+    queryKey: ['archive', archive.id, 'delete-impact'],
+    queryFn: () => api.getArchiveDeleteImpact(archive.id),
+    enabled: showDeleteConfirm,
+    staleTime: 0,
+  });
   const navigate = useNavigate();
   const [showReprint, setShowReprint] = useState(false);
   const [showSliceModal, setShowSliceModal] = useState(false);
@@ -2273,6 +2310,7 @@ function ArchiveListRow({
           message={t('archives.modal.deleteConfirm', { name: archive.print_name || archive.filename })}
           confirmText={t('archives.modal.deleteButton')}
           variant="danger"
+          confirmDisabled={(deleteImpactQuery.data?.currently_printing ?? 0) > 0}
           onConfirm={() => {
             deleteMutation.mutate(deletePurgeStats);
             setShowDeleteConfirm(false);
@@ -2283,6 +2321,25 @@ function ArchiveListRow({
             setDeletePurgeStats(false);
           }}
         >
+          {/* #1734: warn the user when related queue items will also be removed,
+              and block the action entirely if any are currently printing. */}
+          {(deleteImpactQuery.data?.related_queue_items ?? 0) > 0 && (
+            <div
+              className={
+                (deleteImpactQuery.data?.currently_printing ?? 0) > 0
+                  ? 'text-sm text-red-400 mb-2'
+                  : 'text-sm text-amber-400 mb-2'
+              }
+            >
+              {(deleteImpactQuery.data?.currently_printing ?? 0) > 0
+                ? t('archives.modal.deleteBlockedByPrinting', {
+                    count: deleteImpactQuery.data!.currently_printing,
+                  })
+                : t('archives.modal.deleteQueueItemsWarning', {
+                    count: deleteImpactQuery.data!.related_queue_items,
+                  })}
+            </div>
+          )}
           {/* #1343: opt-in checkbox — by default the archive is soft-deleted,
               so its filament / time / cost contribution stays in Quick Stats. */}
           <label className="flex items-start gap-2 cursor-pointer text-sm text-bambu-gray">

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/index-D0SWpeco.js


+ 1 - 1
static/index.html

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

Některé soubory nejsou zobrazeny, neboť je v těchto rozdílových datech změněno mnoho souborů