Parcourir la source

feat(queue): a job sent with a spool of another colour says so on its row (#3279)

- When a job is sent, each printed slot's colour (after overrides) is
  compared with the spool it is mapped to, the way the matcher compares;
  slots that differ are stored in the new print_queue column
  color_substitutions, written with the move to printing.
- The queue row and the History row show one amber line per slot:
  "Slot 1 printed in Black (#000000), the file asks for Silver
  (#A4AAAC)". 15 locales.
- Cleared when the print never started: deferred back to pending,
  failed before the start, or reverted/failed by the start watchdog.
- Spools without a colour set are not reported (color_known on the
  loaded filament list); malformed stored entries read as no note.
maziggy il y a 1 jour
Parent
commit
918353a945
73 fichiers modifiés avec 563 ajouts et 6 suppressions
  1. 5 0
      CHANGELOG.md
  2. 14 0
      backend/app/api/routes/print_queue.py
  3. 5 0
      backend/app/core/database.py
  4. 6 0
      backend/app/models/print_queue.py
  5. 13 0
      backend/app/schemas/print_queue.py
  6. 86 3
      backend/app/services/print_scheduler.py
  7. 20 0
      backend/tests/integration/test_print_queue_api.py
  8. 232 0
      backend/tests/unit/test_color_substitutions_3279.py
  9. 17 0
      backend/tests/unit/test_scheduler_watchdog.py
  10. 34 0
      frontend/src/__tests__/pages/QueuePage.test.tsx
  11. 15 0
      frontend/src/api/client.ts
  12. 41 0
      frontend/src/components/ColorSubstitutionNotes.tsx
  13. 4 0
      frontend/src/components/CompactHistoryRow.tsx
  14. 4 0
      frontend/src/i18n/locales/de.ts
  15. 4 0
      frontend/src/i18n/locales/en.ts
  16. 4 0
      frontend/src/i18n/locales/es.ts
  17. 4 0
      frontend/src/i18n/locales/fr.ts
  18. 4 0
      frontend/src/i18n/locales/it.ts
  19. 4 0
      frontend/src/i18n/locales/ja.ts
  20. 4 0
      frontend/src/i18n/locales/ko.ts
  21. 4 0
      frontend/src/i18n/locales/nl.ts
  22. 4 0
      frontend/src/i18n/locales/pt-BR.ts
  23. 4 0
      frontend/src/i18n/locales/ru.ts
  24. 4 0
      frontend/src/i18n/locales/sv.ts
  25. 4 0
      frontend/src/i18n/locales/tr.ts
  26. 4 0
      frontend/src/i18n/locales/uk.ts
  27. 4 0
      frontend/src/i18n/locales/zh-CN.ts
  28. 4 0
      frontend/src/i18n/locales/zh-TW.ts
  29. 8 0
      frontend/src/pages/QueuePage.tsx
  30. 0 0
      static/assets/ArchivesPage-8vBHyzCo.js
  31. 0 0
      static/assets/CameraPage-D_d4E29T.js
  32. 0 0
      static/assets/FileManagerPage-_qdAzVsc.js
  33. 0 0
      static/assets/InventoryPage-8RhZkDKr.js
  34. 0 0
      static/assets/Layout-F1iGI_1j.js
  35. 0 0
      static/assets/ModelSourcesPage-BW3KDLUO.js
  36. 0 0
      static/assets/PhotoGalleryModal-CnAVA-Fw.js
  37. 0 0
      static/assets/PrintersPage-DfVKE0S_.js
  38. 0 0
      static/assets/QueuePage-CKosToJ2.js
  39. 0 0
      static/assets/QueuePage-DFS4se7g.js
  40. 0 0
      static/assets/SettingsPage-CWK1_dBW.js
  41. 0 0
      static/assets/SliceModal-C09WTp7q.js
  42. 1 1
      static/assets/SpoolBuddySettingsPage-DMcAk9_a.js
  43. 0 0
      static/assets/de-CP5J7a-n.js
  44. 0 0
      static/assets/de-D39JI_8d.js
  45. 0 0
      static/assets/es-BHL-6pV1.js
  46. 0 0
      static/assets/es-ucd2Rt5d.js
  47. 0 0
      static/assets/fr-CpEo28WL.js
  48. 0 0
      static/assets/fr-IxLLv2H_.js
  49. 0 0
      static/assets/index-BSJ3-ebL.js
  50. 0 0
      static/assets/it-CFRvX4yY.js
  51. 0 0
      static/assets/it-DKgH5LEK.js
  52. 0 0
      static/assets/ja-C4JWWL34.js
  53. 0 0
      static/assets/ja-vehaWIqv.js
  54. 0 0
      static/assets/ko-CREU6ze4.js
  55. 0 0
      static/assets/ko-CbTWUFFi.js
  56. 0 0
      static/assets/locationSensorDefaults-af_GVFMj.js
  57. 0 0
      static/assets/nl-CixN_KdW.js
  58. 0 0
      static/assets/nl-DBMExM6i.js
  59. 0 0
      static/assets/pt-BR-DBfeh188.js
  60. 0 0
      static/assets/pt-BR-DqnpZVu5.js
  61. 0 0
      static/assets/ru-Bw5Xt0Zp.js
  62. 0 0
      static/assets/ru-rVUUdG_R.js
  63. 0 0
      static/assets/sv-Cqpp_hoE.js
  64. 0 0
      static/assets/sv-DQBLU3Nr.js
  65. 0 0
      static/assets/tr-C3iyyu_Z.js
  66. 0 0
      static/assets/tr-Fokmo5P7.js
  67. 0 0
      static/assets/uk-DO3--z4v.js
  68. 0 0
      static/assets/uk-DYl3Aek2.js
  69. 0 0
      static/assets/zh-CN-D7IpC6DP.js
  70. 0 0
      static/assets/zh-CN-YcuBUFHM.js
  71. 0 0
      static/assets/zh-TW-DARWgtcC.js
  72. 0 0
      static/assets/zh-TW-Dq_Gi0kY.js
  73. 2 2
      static/index.html

+ 5 - 0
CHANGELOG.md

@@ -5,6 +5,11 @@ All notable changes to Bambuddy will be documented in this file.
 ## [1.3.0b1] - Unreleased
 ## [1.3.0b1] - Unreleased
 
 
 ### Added
 ### Added
+- **The queue says when a job printed in a colour the file didn't ask for (#3279, requested by @Lioncat55)** — With no spool of a slot's colour loaded, the queue can still send the job with another spool of the same material. Nothing showed this until the print came out.
+  - **On the queue row:** an amber line per slot, for example "Slot 1 printed in Black (#000000), the file asks for Silver (#A4AAAC)". It stays on the job in **History**.
+  - **When:** worked out each time the job is sent, from the spool each slot is sent with, whether the queue picked it or it was picked by hand. A similar colour doesn't count, and a colour changed on purpose with a filament override isn't reported.
+  - **To have the queue wait** for a spool of the file's colour instead of using another one, turn on **Force color match**.
+  - **Only for prints that started:** a send that goes back to the queue or fails before the print starts carries no note.
 - **A count on an app's sidebar link** — An app added to the sidebar can show a number on its link, the way the print queue shows how many jobs wait: Bambuddy Orders shows how many orders and top-ups wait in its To do. The app reports the count for its own address with an API key that has **Send notifications** (`PUT /api/v1/external-links/badge`); the link must point at that address. A count not updated for ten minutes disappears, so an app that stopped doesn't leave an old number. Wiki updated.
 - **A count on an app's sidebar link** — An app added to the sidebar can show a number on its link, the way the print queue shows how many jobs wait: Bambuddy Orders shows how many orders and top-ups wait in its To do. The app reports the count for its own address with an API key that has **Send notifications** (`PUT /api/v1/external-links/badge`); the link must point at that address. A count not updated for ten minutes disappears, so an app that stopped doesn't leave an old number. Wiki updated.
 - **USB cameras are picked from a list** — With an external camera set to **USB Camera (V4L2)**, Settings > Camera now lists the video devices the Bambuddy server can see, by name and path (`HD Pro Webcam C920 (/dev/video0)`), instead of asking for the device path. A refresh button looks again after a camera is plugged in, and a saved camera that is unplugged stays selected, marked **not found**. When no device is visible, the list says how to pass a camera to the Docker container. **Enter path manually** still takes a typed path. Looking for devices no longer holds up the server while it runs.
 - **USB cameras are picked from a list** — With an external camera set to **USB Camera (V4L2)**, Settings > Camera now lists the video devices the Bambuddy server can see, by name and path (`HD Pro Webcam C920 (/dev/video0)`), instead of asking for the device path. A refresh button looks again after a camera is plugged in, and a saved camera that is unplugged stays selected, marked **not found**. When no device is visible, the list says how to pass a camera to the Docker container. **Enter path manually** still takes a typed path. Looking for devices no longer holds up the server while it runs.
 - **The MQTT relay publishes printer offline/online and low filament** — Three new topics for Home Assistant, Node-RED and other subscribers. `printers/{serial}/offline` is sent once a printer has been disconnected for a minute, and `printers/{serial}/online` when it comes back after that, so a short blip sends neither. `printers/{serial}/filament/low` is sent once when the spool in an AMS slot drops below its low-stock threshold, at the same moment as the Low Filament notification, and works even when no notification provider has that event switched on. Wiki updated with the payloads.
 - **The MQTT relay publishes printer offline/online and low filament** — Three new topics for Home Assistant, Node-RED and other subscribers. `printers/{serial}/offline` is sent once a printer has been disconnected for a minute, and `printers/{serial}/online` when it comes back after that, so a short blip sends neither. `printers/{serial}/filament/low` is sent once when the spool in an AMS slot drops below its low-stock threshold, at the same moment as the Low Filament notification, and works even when no notification provider has that event switched on. Wiki updated with the payloads.

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

@@ -10,6 +10,7 @@ from typing import Any, Literal
 
 
 import defusedxml.ElementTree as ET
 import defusedxml.ElementTree as ET
 from fastapi import APIRouter, Depends, HTTPException, Query
 from fastapi import APIRouter, Depends, HTTPException, Query
+from pydantic import ValidationError
 from sqlalchemy import ColumnElement, UnaryExpression, and_, case, func, inspect, or_, select, update
 from sqlalchemy import ColumnElement, UnaryExpression, and_, case, func, inspect, or_, select, update
 from sqlalchemy.exc import IntegrityError
 from sqlalchemy.exc import IntegrityError
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
@@ -39,6 +40,7 @@ from backend.app.models.printer import Printer
 from backend.app.models.project import Project
 from backend.app.models.project import Project
 from backend.app.models.user import User
 from backend.app.models.user import User
 from backend.app.schemas.print_queue import (
 from backend.app.schemas.print_queue import (
+    ColorSubstitution,
     PrintBatchCreate,
     PrintBatchCreate,
     PrintBatchDispatchRequest,
     PrintBatchDispatchRequest,
     PrintBatchPlateProgress,
     PrintBatchPlateProgress,
@@ -469,6 +471,17 @@ def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
         except json.JSONDecodeError:
         except json.JSONDecodeError:
             nozzle_rack_choice_parsed = None
             nozzle_rack_choice_parsed = None
 
 
+    # Slots sent with a spool of another colour (#3279), for the queue row.
+    color_substitutions_parsed = None
+    if item.color_substitutions:
+        try:
+            parsed = json.loads(item.color_substitutions)
+            # Validated here, not by the response model: one bad row must not
+            # fail the whole queue list.
+            color_substitutions_parsed = [ColorSubstitution.model_validate(entry).model_dump() for entry in parsed]
+        except (json.JSONDecodeError, TypeError, ValidationError):
+            color_substitutions_parsed = None
+
     nozzles_info_parsed = None
     nozzles_info_parsed = None
     if item.nozzles_info:
     if item.nozzles_info:
         try:
         try:
@@ -527,6 +540,7 @@ def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
         "nozzle_mapping": nozzle_mapping_parsed,
         "nozzle_mapping": nozzle_mapping_parsed,
         "nozzle_rack_choice": nozzle_rack_choice_parsed,
         "nozzle_rack_choice": nozzle_rack_choice_parsed,
         "nozzles_info": nozzles_info_parsed,
         "nozzles_info": nozzles_info_parsed,
+        "color_substitutions": color_substitutions_parsed,
         "cleanup_library_after_dispatch": item.cleanup_library_after_dispatch,
         "cleanup_library_after_dispatch": item.cleanup_library_after_dispatch,
         # Cross-model alternatives (#671). Guarded rather than read directly:
         # Cross-model alternatives (#671). Guarded rather than read directly:
         # every route that reaches here eager-loads the relationship, but a
         # every route that reaches here eager-loads the relationship, but a

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

@@ -5858,6 +5858,11 @@ async def _run_migrations(conn):
     # default -- identical DDL on SQLite and Postgres.
     # default -- identical DDL on SQLite and Postgres.
     await _safe_execute(conn, "ALTER TABLE print_archives ADD COLUMN timelapse_plate_id INTEGER")
     await _safe_execute(conn, "ALTER TABLE print_archives ADD COLUMN timelapse_plate_id INTEGER")
 
 
+    # Migration: slots a queue item was sent with in another colour than the
+    # file asked for (#3279). Nullable TEXT (JSON), no default -- identical DDL
+    # on SQLite and Postgres.
+    await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN color_substitutions TEXT")
+
     await _migrate_auth_datetimes_to_timestamp(conn)
     await _migrate_auth_datetimes_to_timestamp(conn)
     await _migrate_add_missing_set_null_fks(conn)
     await _migrate_add_missing_set_null_fks(conn)
     await _migrate_wallet_transactions_keep_deleted_users(conn)
     await _migrate_wallet_transactions_keep_deleted_users(conn)

+ 6 - 0
backend/app/models/print_queue.py

@@ -103,6 +103,12 @@ class PrintQueueItem(Base):
     # was picked, and the dispatcher assigns positions itself.
     # was picked, and the dispatcher assigns positions itself.
     nozzle_rack_choice: Mapped[str | None] = mapped_column(Text, nullable=True)
     nozzle_rack_choice: Mapped[str | None] = mapped_column(Text, nullable=True)
 
 
+    # Slots sent with a spool of another colour than the file asked for (#3279),
+    # as a JSON list of {slot_id, type, wanted, got, tray}. Worked out from the
+    # mapping each time the job is sent, so a retry replaces it. NULL when every
+    # slot got its colour or nothing could be compared.
+    color_substitutions: Mapped[str | None] = mapped_column(Text, nullable=True)
+
     # Printer-card direct uploads create transient library rows. When this is
     # Printer-card direct uploads create transient library rows. When this is
     # true, the scheduler deletes the source row/files after archiving a copy.
     # true, the scheduler deletes the source row/files after archiving a copy.
     cleanup_library_after_dispatch: Mapped[bool] = mapped_column(Boolean, default=False, server_default=false())
     cleanup_library_after_dispatch: Mapped[bool] = mapped_column(Boolean, default=False, server_default=false())

+ 13 - 0
backend/app/schemas/print_queue.py

@@ -202,6 +202,16 @@ class QueueVariantSummary(BaseModel):
     position: int
     position: int
 
 
 
 
+class ColorSubstitution(BaseModel):
+    """A slot sent with a spool of another colour than the file asked for (#3279)."""
+
+    slot_id: int
+    type: str
+    wanted: str
+    got: str
+    tray: int
+
+
 class PrintQueueItemResponse(BaseModel):
 class PrintQueueItemResponse(BaseModel):
     id: int
     id: int
     printer_id: int | None  # None = unassigned
     printer_id: int | None  # None = unassigned
@@ -307,6 +317,9 @@ class PrintQueueItemResponse(BaseModel):
     # live rack at dispatch; null means "assign them for me".
     # live rack at dispatch; null means "assign them for me".
     nozzle_rack_choice: dict[int, int] | None = None
     nozzle_rack_choice: dict[int, int] | None = None
 
 
+    # Slots that printed in another colour than the file asked for (#3279).
+    color_substitutions: list[ColorSubstitution] | None = None
+
     class Config:
     class Config:
         from_attributes = True
         from_attributes = True
 
 

+ 86 - 3
backend/app/services/print_scheduler.py

@@ -4595,6 +4595,9 @@ class PrintScheduler:
                         {
                         {
                             "type": tray_type,
                             "type": tray_type,
                             "color": color,
                             "color": color,
+                            # `color` reads a missing colour as #808080; this
+                            # says whether the printer reported one at all.
+                            "color_known": bool(tray_color),
                             "tray_info_idx": tray_info_idx,
                             "tray_info_idx": tray_info_idx,
                             "ams_id": ams_id,
                             "ams_id": ams_id,
                             "tray_id": tray_id,
                             "tray_id": tray_id,
@@ -4615,6 +4618,7 @@ class PrintScheduler:
                     {
                     {
                         "type": vt["tray_type"],
                         "type": vt["tray_type"],
                         "color": color,
                         "color": color,
+                        "color_known": bool(vt.get("tray_color")),
                         "tray_info_idx": vt.get("tray_info_idx", ""),
                         "tray_info_idx": vt.get("tray_info_idx", ""),
                         "ams_id": -1,
                         "ams_id": -1,
                         "tray_id": idx,
                         "tray_id": idx,
@@ -8347,6 +8351,69 @@ class PrintScheduler:
             await db.commit()
             await db.commit()
         return False
         return False
 
 
+    def _find_color_substitutions(self, required: list[dict], mapping: list, loaded: list[dict]) -> list[dict]:
+        """The slots ``mapping`` gives a spool of another colour than they ask for (#3279).
+
+        Compared the way the matcher compares, so the same hex or a similar one
+        is a match. Only slots the plate prints, mapped to a tray the printer
+        reports, with a colour on both sides: anything else is not a colour the
+        job printed in.
+        """
+        by_tray = {f["global_tray_id"]: f for f in loaded}
+        found = []
+        for req in required:
+            slot_id = req.get("slot_id") or 0
+            if slot_id <= 0 or slot_id > len(mapping):
+                continue
+            tray_id = mapping[slot_id - 1]
+            tray = by_tray.get(tray_id) if _is_tray_id(tray_id) else None
+            if tray is not None and tray.get("color_known") is False:
+                # A spool without a colour set is not a different colour.
+                continue
+            wanted = self._normalize_color_for_compare(req.get("color"))
+            got = self._normalize_color_for_compare(tray.get("color")) if tray else ""
+            if not wanted or not got or wanted == got or self._colors_are_similar(wanted, got):
+                continue
+            found.append(
+                {
+                    "slot_id": slot_id,
+                    "type": req.get("type") or "",
+                    "wanted": f"#{wanted.upper()}",
+                    "got": f"#{got.upper()}",
+                    "tray": tray_id,
+                }
+            )
+        return found
+
+    async def _color_substitutions_for(self, db: AsyncSession, item: PrintQueueItem) -> str | None:
+        """``item``'s colour substitutions as stored on the row, or None (#3279).
+
+        Read from the mapping the job is about to be sent with, whoever made
+        it: the matcher's same-material fallback, a preset match in another
+        colour, or a slot picked by hand. The file's colour is the one after
+        overrides, so a colour swapped on purpose is not one. None whenever
+        there is nothing to compare, and on any failure: a missing note must
+        never stop a print.
+        """
+        try:
+            mapping = json.loads(item.ams_mapping) if item.ams_mapping else None
+            if not isinstance(mapping, list) or not item.printer_id:
+                return None
+            status = printer_manager.get_status(item.printer_id)
+            if status is None:
+                return None
+            required = await self._get_filament_requirements(db, item)
+            if not required:
+                return None
+            self._apply_filament_overrides(item, required)
+            found = self._find_color_substitutions(required, mapping, self._build_loaded_filaments(status))
+        except Exception as e:
+            logger.debug("Queue item %s: colour comparison failed: %s", item.id, e)
+            return None
+        if found:
+            logger.info("Queue item %s: sent with another colour than the file asks for: %s", item.id, found)
+        return json.dumps(found) if found else None
+
     async def _unmatched_requirements(
     async def _unmatched_requirements(
         self, db: AsyncSession, item: PrintQueueItem, printer_id: int, mapping: list | None
         self, db: AsyncSession, item: PrintQueueItem, printer_id: int, mapping: list | None
     ) -> list[dict]:
     ) -> list[dict]:
@@ -8547,7 +8614,13 @@ class PrintScheduler:
                 update(PrintQueueItem)
                 update(PrintQueueItem)
                 .where(PrintQueueItem.id == item.id)
                 .where(PrintQueueItem.id == item.id)
                 .where(PrintQueueItem.status.in_(("pending", "printing")))
                 .where(PrintQueueItem.status.in_(("pending", "printing")))
-                .values(status="failed", error_message=message, completed_at=datetime.now(timezone.utc))
+                # Failed before the print started: no slot printed in any colour (#3279).
+                .values(
+                    status="failed",
+                    error_message=message,
+                    completed_at=datetime.now(timezone.utc),
+                    color_substitutions=None,
+                )
                 .execution_options(synchronize_session=False)
                 .execution_options(synchronize_session=False)
             ),
             ),
         )
         )
@@ -8572,7 +8645,7 @@ class PrintScheduler:
             update(PrintQueueItem)
             update(PrintQueueItem)
             .where(PrintQueueItem.id == item.id)
             .where(PrintQueueItem.id == item.id)
             .where(PrintQueueItem.status == "printing")
             .where(PrintQueueItem.status == "printing")
-            .values(status="pending", started_at=None)
+            .values(status="pending", started_at=None, color_substitutions=None)
             .execution_options(synchronize_session=False)
             .execution_options(synchronize_session=False)
         )
         )
         await db.commit()
         await db.commit()
@@ -9286,11 +9359,19 @@ class PrintScheduler:
         # the file we just uploaded, do NOT send start_print.
         # the file we just uploaded, do NOT send start_print.
         now_utc = datetime.now(timezone.utc)
         now_utc = datetime.now(timezone.utc)
         billing_run_id = str(uuid.uuid4())
         billing_run_id = str(uuid.uuid4())
+        # Written with the move to printing, so a retried send replaces the
+        # last answer and a cancelled one leaves the row alone (#3279).
+        color_substitutions = await self._color_substitutions_for(db, item)
         cas = await db.execute(
         cas = await db.execute(
             update(PrintQueueItem)
             update(PrintQueueItem)
             .where(PrintQueueItem.id == item.id)
             .where(PrintQueueItem.id == item.id)
             .where(PrintQueueItem.status == "pending")
             .where(PrintQueueItem.status == "pending")
-            .values(status="printing", started_at=now_utc, billing_run_id=billing_run_id)
+            .values(
+                status="printing",
+                started_at=now_utc,
+                billing_run_id=billing_run_id,
+                color_substitutions=color_substitutions,
+            )
         )
         )
         await db.commit()
         await db.commit()
         if cas.rowcount == 0:
         if cas.rowcount == 0:
@@ -9947,6 +10028,8 @@ class PrintScheduler:
                 return "already_moved_on"
                 return "already_moved_on"
             item.dispatch_attempts = (item.dispatch_attempts or 0) + 1
             item.dispatch_attempts = (item.dispatch_attempts or 0) + 1
             item.started_at = None
             item.started_at = None
+            # The print never started: no slot printed in any colour (#3279).
+            item.color_substitutions = None
             # Charge the attempt to the candidate that was actually dispatched, so
             # Charge the attempt to the candidate that was actually dispatched, so
             # a cross-model item (#671) reaches for its other file next lap instead
             # a cross-model item (#671) reaches for its other file next lap instead
             # of retrying the printer that just failed to start. Matched by file
             # of retrying the printer that just failed to start. Matched by file

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

@@ -1,5 +1,7 @@
 """Integration tests for Print Queue API endpoints."""
 """Integration tests for Print Queue API endpoints."""
 
 
+import json
+
 import pytest
 import pytest
 from httpx import AsyncClient
 from httpx import AsyncClient
 from sqlalchemy import select
 from sqlalchemy import select
@@ -433,6 +435,24 @@ class TestPrintQueueAPI:
         assert response.json()["cost_center_id"] == cost_center.id
         assert response.json()["cost_center_id"] == cost_center.id
         assert response.json()["estimated_cost"] == 1.25
         assert response.json()["estimated_cost"] == 1.25
 
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_colour_substitutions_are_returned_parsed(self, async_client: AsyncClient, queue_item_factory):
+        """#3279: the queue row reads which slots printed in another colour."""
+        subs = [{"slot_id": 1, "type": "PLA", "wanted": "#A4AAAC", "got": "#000000", "tray": 1}]
+        item = await queue_item_factory(color_substitutions=json.dumps(subs))
+        unreadable = await queue_item_factory(color_substitutions="{")
+        not_a_list = await queue_item_factory(color_substitutions='{"slot_id": 1}')
+        bad_entries = await queue_item_factory(color_substitutions='[1, {"slot_id": 1}]')
+
+        assert (await async_client.get(f"/api/v1/queue/{item.id}")).json()["color_substitutions"] == subs
+        assert (await async_client.get(f"/api/v1/queue/{unreadable.id}")).json()["color_substitutions"] is None
+        assert (await async_client.get(f"/api/v1/queue/{not_a_list.id}")).json()["color_substitutions"] is None
+        assert (await async_client.get(f"/api/v1/queue/{bad_entries.id}")).json()["color_substitutions"] is None
+        # One bad row must not fail the whole list.
+        listed = (await async_client.get("/api/v1/queue/")).json()
+        assert {i["id"]: i["color_substitutions"] for i in listed}[item.id] == subs
+
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
     async def test_add_to_queue_with_project_id(
     async def test_add_to_queue_with_project_id(

+ 232 - 0
backend/tests/unit/test_color_substitutions_3279.py

@@ -0,0 +1,232 @@
+"""A job sent with a spool of a colour the file did not ask for is recorded (#3279).
+
+When no spool of the file's colour is loaded, the matcher can still give a slot
+a spool of the same material in another colour (``type_only``), and a slot
+picked by hand can be any colour. Nothing said so: the reporter found out from
+the print. Now the colours are compared when the job is sent, and the slots
+that differ are kept on the queue item for the queue row to show.
+
+Compared as the matcher compares: the same hex or a similar one is a match.
+The file's colour is the one after overrides, so a colour swapped on purpose
+is not reported.
+"""
+
+import json
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from backend.app.models.print_queue import PrintQueueItem
+from backend.app.services.print_scheduler import PrintScheduler
+from backend.tests.unit.test_scheduler_cancel_race import (
+    _dispatch,
+    queue_factory,  # noqa: F401 - fixture
+)
+
+GREY = {"slot_id": 1, "type": "PLA", "color": "#A4AAAC", "tray_info_idx": "GFA00"}
+
+TRAYS = [
+    {"global_tray_id": 0, "type": "PLA", "color": "#A4AAAC"},
+    {"global_tray_id": 1, "type": "PLA", "color": "#000000"},
+    {"global_tray_id": 2, "type": "PLA", "color": "#A0A8AA"},
+    {"global_tray_id": 254, "type": "PLA", "color": "#FFFFFF"},
+]
+
+
+class TestFindColorSubstitutions:
+    def find(self, required, mapping, loaded=TRAYS):
+        return PrintScheduler()._find_color_substitutions(required, mapping, loaded)
+
+    def test_a_spool_of_another_colour_is_reported(self):
+        assert self.find([GREY], [1]) == [
+            {"slot_id": 1, "type": "PLA", "wanted": "#A4AAAC", "got": "#000000", "tray": 1}
+        ]
+
+    def test_the_external_spool_is_compared_too(self):
+        assert self.find([GREY], [254])[0]["got"] == "#FFFFFF"
+
+    def test_the_same_colour_is_not(self):
+        assert self.find([GREY], [0]) == []
+
+    def test_a_similar_colour_is_not(self):
+        """The matcher calls it a match, so the queue row does too."""
+        assert self.find([GREY], [2]) == []
+
+    def test_an_unmapped_slot_is_not(self):
+        """Not a colour the job printed in; the hold covers it."""
+        assert self.find([GREY], [-1]) == []
+        assert self.find([{**GREY, "slot_id": 3}], [0]) == []
+
+    def test_a_tray_the_printer_does_not_report_is_not(self):
+        assert self.find([GREY], [7]) == []
+
+    def test_a_boolean_is_not_read_as_tray_one(self):
+        assert self.find([GREY], [True]) == []
+
+    def test_a_spool_without_a_colour_is_not(self):
+        """The loaded list reads it as #808080; that is no colour, not grey."""
+        loaded = [{"global_tray_id": 1, "type": "PLA", "color": "#808080", "color_known": False}]
+        assert self.find([GREY], [1], loaded) == []
+
+    def test_the_loaded_list_says_whether_the_spool_has_a_colour(self):
+        status = MagicMock(
+            raw_data={
+                "ams": [
+                    {
+                        "id": 0,
+                        "tray": [
+                            {"id": 0, "tray_type": "PLA"},
+                            {"id": 1, "tray_type": "PLA", "tray_color": "000000FF"},
+                        ],
+                    }
+                ],
+                "vt_tray": [{"id": 254, "tray_type": "PLA"}],
+            }
+        )
+        loaded = PrintScheduler()._build_loaded_filaments(status)
+        assert [f["color_known"] for f in loaded] == [False, True, False]
+
+    def test_a_slot_without_a_colour_is_not(self):
+        assert self.find([{**GREY, "color": ""}], [1]) == []
+
+    def test_only_the_slots_that_differ(self):
+        second = {"slot_id": 2, "type": "PLA", "color": "#000000"}
+        assert [s["slot_id"] for s in self.find([GREY, second], [1, 1])] == [1]
+
+
+class TestColorSubstitutionsFor:
+    def _scheduler(self, requirements=(GREY,)):
+        scheduler = PrintScheduler()
+        scheduler._build_loaded_filaments = MagicMock(return_value=[dict(t) for t in TRAYS])
+        scheduler._get_filament_requirements = AsyncMock(
+            return_value=[dict(r) for r in requirements] if requirements else None
+        )
+        return scheduler
+
+    @staticmethod
+    def _item(**fields):
+        item = MagicMock()
+        item.id = 5
+        item.printer_id = 4
+        item.ams_mapping = json.dumps([1])
+        item.filament_overrides = None
+        for key, value in fields.items():
+            setattr(item, key, value)
+        return item
+
+    @pytest.mark.asyncio
+    async def test_reports_the_substituted_slot(self):
+        with patch("backend.app.services.print_scheduler.printer_manager") as pm:
+            pm.get_status.return_value = MagicMock()
+            found = await self._scheduler()._color_substitutions_for(AsyncMock(), self._item())
+        assert json.loads(found) == [{"slot_id": 1, "type": "PLA", "wanted": "#A4AAAC", "got": "#000000", "tray": 1}]
+
+    @pytest.mark.asyncio
+    async def test_a_colour_swapped_on_purpose_is_not_reported(self):
+        swapped = json.dumps([{"slot_id": 1, "type": "PLA", "color": "#000000"}])
+        with patch("backend.app.services.print_scheduler.printer_manager") as pm:
+            pm.get_status.return_value = MagicMock()
+            found = await self._scheduler()._color_substitutions_for(
+                AsyncMock(), self._item(filament_overrides=swapped)
+            )
+        assert found is None
+
+    @pytest.mark.asyncio
+    @pytest.mark.parametrize(
+        "fields",
+        [{"ams_mapping": None}, {"ams_mapping": "not json"}, {"ams_mapping": '{"0": 1}'}, {"printer_id": None}],
+    )
+    async def test_nothing_to_compare_records_nothing(self, fields):
+        with patch("backend.app.services.print_scheduler.printer_manager") as pm:
+            pm.get_status.return_value = MagicMock()
+            found = await self._scheduler()._color_substitutions_for(AsyncMock(), self._item(**fields))
+        assert found is None
+
+    @pytest.mark.asyncio
+    async def test_no_status_or_no_file_records_nothing(self):
+        with patch("backend.app.services.print_scheduler.printer_manager") as pm:
+            pm.get_status.return_value = None
+            assert await self._scheduler()._color_substitutions_for(AsyncMock(), self._item()) is None
+            pm.get_status.return_value = MagicMock()
+            assert await self._scheduler(requirements=None)._color_substitutions_for(AsyncMock(), self._item()) is None
+
+    @pytest.mark.asyncio
+    async def test_a_failing_check_records_nothing(self):
+        scheduler = self._scheduler()
+        scheduler._get_filament_requirements = AsyncMock(side_effect=RuntimeError("boom"))
+        with patch("backend.app.services.print_scheduler.printer_manager") as pm:
+            pm.get_status.return_value = MagicMock()
+            assert await scheduler._color_substitutions_for(AsyncMock(), self._item()) is None
+
+
+SUBSTITUTED = json.dumps([{"slot_id": 1, "type": "PLA", "wanted": "#A4AAAC", "got": "#000000", "tray": 1}])
+
+
+class TestRecordedWhenSent:
+    @pytest.mark.asyncio
+    async def test_stored_with_the_move_to_printing(self, queue_factory):
+        ctx = await queue_factory()
+
+        with patch.object(PrintScheduler, "_color_substitutions_for", AsyncMock(return_value=SUBSTITUTED)):
+            await _dispatch(ctx)
+
+        async with ctx.session_maker() as db:
+            item = await db.get(PrintQueueItem, ctx.queue_item_id)
+        assert item.status == "printing"
+        assert item.color_substitutions == SUBSTITUTED
+
+    @pytest.mark.asyncio
+    async def test_a_new_send_replaces_the_last_answer(self, queue_factory):
+        """A retried job is compared again; a stale note must not survive."""
+        ctx = await queue_factory()
+        async with ctx.session_maker() as db:
+            item = await db.get(PrintQueueItem, ctx.queue_item_id)
+            item.color_substitutions = SUBSTITUTED
+            await db.commit()
+
+        with patch.object(PrintScheduler, "_color_substitutions_for", AsyncMock(return_value=None)):
+            await _dispatch(ctx)
+
+        async with ctx.session_maker() as db:
+            item = await db.get(PrintQueueItem, ctx.queue_item_id)
+        assert item.status == "printing"
+        assert item.color_substitutions is None
+
+
+class TestDroppedWhenThePrintNeverStarts:
+    """Written with the move to printing, which comes before the print command.
+    A send that goes back to the queue or fails there printed in no colour."""
+
+    async def _printing_with_a_note(self, ctx):
+        async with ctx.session_maker() as db:
+            item = await db.get(PrintQueueItem, ctx.queue_item_id)
+            item.status = "printing"
+            item.color_substitutions = SUBSTITUTED
+            await db.commit()
+
+    @pytest.mark.asyncio
+    async def test_back_to_the_queue(self, queue_factory):
+        ctx = await queue_factory()
+        await self._printing_with_a_note(ctx)
+
+        async with ctx.session_maker() as db:
+            item = await db.get(PrintQueueItem, ctx.queue_item_id)
+            await PrintScheduler._defer_until_printer_idle(db, item)
+
+        async with ctx.session_maker() as db:
+            item = await db.get(PrintQueueItem, ctx.queue_item_id)
+        assert (item.status, item.color_substitutions) == ("pending", None)
+
+    @pytest.mark.asyncio
+    async def test_failed_before_it_started(self, queue_factory):
+        ctx = await queue_factory()
+        await self._printing_with_a_note(ctx)
+
+        with patch("backend.app.services.print_batch.refresh_batches_after", AsyncMock()):
+            async with ctx.session_maker() as db:
+                item = await db.get(PrintQueueItem, ctx.queue_item_id)
+                assert await PrintScheduler()._fail_dispatch(db, item, "start command failed")
+
+        async with ctx.session_maker() as db:
+            item = await db.get(PrintQueueItem, ctx.queue_item_id)
+        assert (item.status, item.color_substitutions) == ("failed", None)

+ 17 - 0
backend/tests/unit/test_scheduler_watchdog.py

@@ -585,6 +585,23 @@ class TestWatchdogRetryBudget:
 
 
         notify.assert_awaited_once()
         notify.assert_awaited_once()
 
 
+    @pytest.mark.asyncio
+    async def test_a_send_that_never_started_drops_its_colour_note(self, db_session):
+        """#3279: the note says a slot printed in another colour; a print that
+        never started printed in none, on the pending row or the failed one."""
+        note = '[{"slot_id": 1, "type": "PLA", "wanted": "#A4AAAC", "got": "#000000", "tray": 1}]'
+        async with db_session() as db:
+            item = await db.get(PrintQueueItem, 1)
+            item.color_substitutions = note
+            await db.commit()
+
+        await self._wedge(db_session)
+
+        async with db_session() as db:
+            item = await db.get(PrintQueueItem, 1)
+            assert item.status == "pending"
+            assert item.color_substitutions is None
+
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     async def test_a_successful_start_never_touches_the_counter(self, db_session):
     async def test_a_successful_start_never_touches_the_counter(self, db_session):
         """Only the revert path increments. A printer that picks the job up
         """Only the revert path increments. A printer that picks the job up

+ 34 - 0
frontend/src/__tests__/pages/QueuePage.test.tsx

@@ -223,6 +223,40 @@ describe('QueuePage', () => {
       });
       });
     });
     });
 
 
+    it('says when a slot printed in another colour than the file asks for (#3279)', async () => {
+      const printing = {
+        ...mockQueueItems[1],
+        id: 91,
+        color_substitutions: [{ slot_id: 1, type: 'PLA', wanted: '#A4AAAC', got: '#000000', tray: 1 }],
+      };
+      const done = {
+        ...mockQueueItems[0],
+        id: 92,
+        status: 'completed',
+        completed_at: '2024-01-02T00:00:00Z',
+        color_substitutions: [{ slot_id: 2, type: 'PLA', wanted: '#FFFFFF', got: '#FF0000', tray: 3 }],
+      };
+      server.use(...serveQueue([printing, done] as MockItem[]));
+
+      render(<QueuePage />);
+
+      const badge = await screen.findByText(/^Slot 1 printed in .*\(#000000\), the file asks for .*\(#A4AAAC\)$/);
+      expect(badge.closest('p')).toHaveAttribute(
+        'title',
+        expect.stringContaining('different colour than the one in the file'),
+      );
+
+      // The History tab draws finished jobs with the compact row.
+      await userEvent.setup().click(await screen.findByRole('button', { name: /^History/ }));
+      expect(await screen.findByText(/^Slot 2 printed in .*\(#FF0000\), the file asks for .*\(#FFFFFF\)$/)).toBeInTheDocument();
+    });
+
+    it('shows no colour note for a job that printed in its colours', async () => {
+      render(<QueuePage />);
+      await screen.findByText('Test Print 1');
+      expect(screen.queryByText(/printed in .*, the file asks for/)).not.toBeInTheDocument();
+    });
+
     it('shows the queue override colour instead of the original 3MF colour (#3132)', async () => {
     it('shows the queue override colour instead of the original 3MF colour (#3132)', async () => {
       const item = {
       const item = {
         ...mockQueueItems[0],
         ...mockQueueItems[0],

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

@@ -2836,6 +2836,21 @@ export interface PrintQueueItem {
    *  machine (#1784): `{ [group_id]: 1-based position }`. Re-checked against
    *  machine (#1784): `{ [group_id]: 1-based position }`. Re-checked against
    *  the live rack at dispatch; omit to have the dispatcher assign them. */
    *  the live rack at dispatch; omit to have the dispatcher assign them. */
   nozzle_rack_choice?: Record<number, number> | null;
   nozzle_rack_choice?: Record<number, number> | null;
+  /** Slots sent with a spool of another colour than the file asks for
+   *  (#3279). Worked out when the job is sent; null when every slot got its
+   *  colour or nothing could be compared. */
+  color_substitutions?: ColorSubstitution[] | null;
+}
+
+/** One slot printed in another colour than the file asked for (#3279). */
+export interface ColorSubstitution {
+  slot_id: number;
+  type: string;
+  /** `#RRGGBB` the file asks for, after overrides. */
+  wanted: string;
+  /** `#RRGGBB` of the spool the slot was sent with. */
+  got: string;
+  tray: number;
 }
 }
 
 
 export interface PrintBatchPlateTarget {
 export interface PrintBatchPlateTarget {

+ 41 - 0
frontend/src/components/ColorSubstitutionNotes.tsx

@@ -0,0 +1,41 @@
+import { AlertCircle } from 'lucide-react';
+import type { ColorSubstitution } from '../api/client';
+import { getColorName } from '../utils/colors';
+
+/**
+ * One line per slot sent with a spool of another colour than the file asks
+ * for (#3279). Shared by the queue row and the compact history row, so a job
+ * says the same thing while it prints and once it is done.
+ */
+export function ColorSubstitutionNotes({
+  substitutions,
+  t,
+  className,
+}: {
+  substitutions: ColorSubstitution[] | null | undefined;
+  t: (key: string, options?: Record<string, unknown>) => string;
+  className: string;
+}) {
+  if (!substitutions || substitutions.length === 0) return null;
+  const label = (hex: string, type: string) => `${getColorName(hex, type)} (${hex})`;
+  return (
+    <>
+      {substitutions.map((sub) => (
+        <p
+          key={`color-sub-${sub.slot_id}`}
+          className={`${className} flex items-start gap-1 text-amber-700 dark:text-amber-400`}
+          title={t('queue.colorSubstitution.rowTooltip')}
+        >
+          <AlertCircle className="w-3 h-3 mt-0.5 flex-shrink-0" />
+          <span>
+            {t('queue.colorSubstitution.rowBadge', {
+              slot: sub.slot_id,
+              got: label(sub.got, sub.type),
+              wanted: label(sub.wanted, sub.type),
+            })}
+          </span>
+        </p>
+      ))}
+    </>
+  );
+}

+ 4 - 0
frontend/src/components/CompactHistoryRow.tsx

@@ -16,6 +16,7 @@ import { queueItemThumbnailUrl } from '../utils/queueItemThumbnail';
 import { type TimeFormat, formatDuration, formatRelativeTime } from '../utils/date';
 import { type TimeFormat, formatDuration, formatRelativeTime } from '../utils/date';
 import type { PrintQueueItem, Permission } from '../api/client';
 import type { PrintQueueItem, Permission } from '../api/client';
 import { Button } from './Button';
 import { Button } from './Button';
+import { ColorSubstitutionNotes } from './ColorSubstitutionNotes';
 import { queueItemDisplayName } from '../utils/queueItemName';
 import { queueItemDisplayName } from '../utils/queueItemName';
 
 
 const STATUS_CONFIG = {
 const STATUS_CONFIG = {
@@ -177,6 +178,9 @@ export function CompactHistoryRow({
         )}
         )}
       </div>
       </div>
 
 
+      {/* Printed with a spool of another colour than the file asks for (#3279). */}
+      <ColorSubstitutionNotes substitutions={item.color_substitutions} t={t} className="mt-1 ml-[3.25rem] text-xs" />
+
       {/* Error message — only rendered on failed/skipped rows. */}
       {/* Error message — only rendered on failed/skipped rows. */}
       {showErrorMessage && (
       {showErrorMessage && (
         <div className="mt-1 ml-[3.25rem] flex items-start gap-1 text-xs text-red-700 dark:text-red-400">
         <div className="mt-1 ml-[3.25rem] flex items-start gap-1 text-xs text-red-700 dark:text-red-400">

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

@@ -1417,6 +1417,10 @@ export default {
       confirmTitle: 'Filament nicht geladen',
       confirmTitle: 'Filament nicht geladen',
       confirmIntro: 'Der Drucker hat kein Fach mit diesem Filament. Legen Sie es ein und drücken Sie erneut auf Start, oder drucken Sie trotzdem:',
       confirmIntro: 'Der Drucker hat kein Fach mit diesem Filament. Legen Sie es ein und drücken Sie erneut auf Start, oder drucken Sie trotzdem:',
     },
     },
+    colorSubstitution: {
+      rowBadge: 'Slot {{slot}} wurde in {{got}} gedruckt, die Datei verlangt {{wanted}}',
+      rowTooltip: 'Die Spule, aus der dieser Slot gedruckt hat, hat eine andere Farbe als die in der Datei: Es war keine Spule in dieser Farbe geladen, oder der Slot wurde von Hand gewählt.',
+    },
     slicerAmsMapping: {
     slicerAmsMapping: {
       rowBadge: 'AMS-Steckplätze für diesen Drucker gespeichert',
       rowBadge: 'AMS-Steckplätze für diesen Drucker gespeichert',
       rowTooltip: 'Dieses Archiv enthält die genauen AMS-Steckplätze, die der Slicer ausgewählt hat, gespeichert für den Drucker dieses Eintrags. Ein erneuter Druck darauf kann diese Fächer wiederverwenden, statt erneut nach Typ und Farbe zuzuordnen.',
       rowTooltip: 'Dieses Archiv enthält die genauen AMS-Steckplätze, die der Slicer ausgewählt hat, gespeichert für den Drucker dieses Eintrags. Ein erneuter Druck darauf kann diese Fächer wiederverwenden, statt erneut nach Typ und Farbe zuzuordnen.',

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

@@ -1431,6 +1431,10 @@ export default {
       confirmTitle: 'Filament not loaded',
       confirmTitle: 'Filament not loaded',
       confirmIntro: 'The printer has no tray with this filament. Load it and press Start again, or print anyway:',
       confirmIntro: 'The printer has no tray with this filament. Load it and press Start again, or print anyway:',
     },
     },
+    colorSubstitution: {
+      rowBadge: 'Slot {{slot}} printed in {{got}}, the file asks for {{wanted}}',
+      rowTooltip: 'The spool this slot printed from is a different colour than the one in the file: no spool of that colour was loaded, or the slot was picked by hand.',
+    },
     slicerAmsMapping: {
     slicerAmsMapping: {
       rowBadge: 'AMS slots saved for this printer',
       rowBadge: 'AMS slots saved for this printer',
       rowTooltip: 'This archive carries the exact AMS slots the slicer picked, saved for the printer this item targets. A reprint on it can reuse those trays instead of matching again by type and colour.',
       rowTooltip: 'This archive carries the exact AMS slots the slicer picked, saved for the printer this item targets. A reprint on it can reuse those trays instead of matching again by type and colour.',

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

@@ -1417,6 +1417,10 @@ export default {
       confirmTitle: 'Filamento no cargado',
       confirmTitle: 'Filamento no cargado',
       confirmIntro: 'La impresora no tiene ninguna bandeja con este filamento. Carguelo y pulse Iniciar de nuevo, o imprima de todos modos:',
       confirmIntro: 'La impresora no tiene ninguna bandeja con este filamento. Carguelo y pulse Iniciar de nuevo, o imprima de todos modos:',
     },
     },
+    colorSubstitution: {
+      rowBadge: 'La ranura {{slot}} imprimió en {{got}}, el archivo pide {{wanted}}',
+      rowTooltip: 'La bobina de esta ranura es de otro color que el del archivo: no había ninguna bobina de ese color cargada o la ranura se eligió a mano.',
+    },
     slicerAmsMapping: {
     slicerAmsMapping: {
       rowBadge: 'Ranuras AMS guardadas para esta impresora',
       rowBadge: 'Ranuras AMS guardadas para esta impresora',
       rowTooltip: 'Este archivo conserva las ranuras AMS exactas que eligió el slicer, guardadas para la impresora de este elemento. Una reimpresión en ella puede reutilizar esas bobinas en lugar de volver a emparejar por tipo y color.',
       rowTooltip: 'Este archivo conserva las ranuras AMS exactas que eligió el slicer, guardadas para la impresora de este elemento. Una reimpresión en ella puede reutilizar esas bobinas en lugar de volver a emparejar por tipo y color.',

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

@@ -1417,6 +1417,10 @@ export default {
       confirmTitle: 'Filament non charge',
       confirmTitle: 'Filament non charge',
       confirmIntro: 'L\'imprimante n\'a aucun emplacement avec ce filament. Chargez-le et appuyez de nouveau sur Demarrer, ou imprimez quand meme :',
       confirmIntro: 'L\'imprimante n\'a aucun emplacement avec ce filament. Chargez-le et appuyez de nouveau sur Demarrer, ou imprimez quand meme :',
     },
     },
+    colorSubstitution: {
+      rowBadge: 'L\'emplacement {{slot}} a imprimé en {{got}}, le fichier demande {{wanted}}',
+      rowTooltip: 'La bobine utilisée par cet emplacement n\'a pas la couleur du fichier : aucune bobine de cette couleur n\'était chargée, ou l\'emplacement a été choisi à la main.',
+    },
     slicerAmsMapping: {
     slicerAmsMapping: {
       rowBadge: 'Emplacements AMS enregistrés pour cette imprimante',
       rowBadge: 'Emplacements AMS enregistrés pour cette imprimante',
       rowTooltip: 'Cette archive conserve les emplacements AMS exacts choisis par le slicer, enregistrés pour l\'imprimante de cet élément. Une réimpression dessus peut réutiliser ces bobines au lieu de refaire la correspondance par type et couleur.',
       rowTooltip: 'Cette archive conserve les emplacements AMS exacts choisis par le slicer, enregistrés pour l\'imprimante de cet élément. Une réimpression dessus peut réutiliser ces bobines au lieu de refaire la correspondance par type et couleur.',

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

@@ -1417,6 +1417,10 @@ export default {
       confirmTitle: 'Filamento non caricato',
       confirmTitle: 'Filamento non caricato',
       confirmIntro: 'La stampante non ha nessuno slot con questo filamento. Caricalo e premi di nuovo Avvia, oppure stampa comunque:',
       confirmIntro: 'La stampante non ha nessuno slot con questo filamento. Caricalo e premi di nuovo Avvia, oppure stampa comunque:',
     },
     },
+    colorSubstitution: {
+      rowBadge: 'Lo slot {{slot}} ha stampato in {{got}}, il file richiede {{wanted}}',
+      rowTooltip: 'La bobina usata da questo slot è di un colore diverso da quello del file: non era caricata nessuna bobina di quel colore oppure lo slot è stato scelto a mano.',
+    },
     slicerAmsMapping: {
     slicerAmsMapping: {
       rowBadge: 'Slot AMS salvati per questa stampante',
       rowBadge: 'Slot AMS salvati per questa stampante',
       rowTooltip: 'Questo archivio conserva gli slot AMS esatti scelti dallo slicer, salvati per la stampante di questo elemento. Una ristampa su di essa può riutilizzare quelle bobine invece di rifare l\'abbinamento per tipo e colore.',
       rowTooltip: 'Questo archivio conserva gli slot AMS esatti scelti dallo slicer, salvati per la stampante di questo elemento. Una ristampa su di essa può riutilizzare quelle bobine invece di rifare l\'abbinamento per tipo e colore.',

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

@@ -1416,6 +1416,10 @@ export default {
       confirmTitle: 'フィラメント未装填',
       confirmTitle: 'フィラメント未装填',
       confirmIntro: 'このフィラメントが装填されたトレイがプリンターにありません。装填してからもう一度開始を押すか、それでも印刷してください:',
       confirmIntro: 'このフィラメントが装填されたトレイがプリンターにありません。装填してからもう一度開始を押すか、それでも印刷してください:',
     },
     },
+    colorSubstitution: {
+      rowBadge: 'スロット {{slot}} は {{got}} で印刷されました(ファイルの指定は {{wanted}})',
+      rowTooltip: 'このスロットで使われたスプールはファイルの色と異なります。その色のスプールが装填されていなかったか、スロットが手動で選ばれました。',
+    },
     slicerAmsMapping: {
     slicerAmsMapping: {
       rowBadge: 'このプリンター用に保存されたAMSスロット',
       rowBadge: 'このプリンター用に保存されたAMSスロット',
       rowTooltip: 'このアーカイブには、スライサーが選択した正確なAMSスロットが、この項目の対象プリンター用に保存されています。そのプリンターでの再印刷では、タイプと色で照合し直す代わりにそれらのトレイを再利用できます。',
       rowTooltip: 'このアーカイブには、スライサーが選択した正確なAMSスロットが、この項目の対象プリンター用に保存されています。そのプリンターでの再印刷では、タイプと色で照合し直す代わりにそれらのトレイを再利用できます。',

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

@@ -1677,6 +1677,10 @@ export default {
       confirmTitle: '필라멘트 미장착',
       confirmTitle: '필라멘트 미장착',
       confirmIntro: '프린터에 이 필라멘트가 장착된 트레이가 없습니다. 장착한 뒤 다시 시작을 누르거나, 그냥 인쇄하세요:',
       confirmIntro: '프린터에 이 필라멘트가 장착된 트레이가 없습니다. 장착한 뒤 다시 시작을 누르거나, 그냥 인쇄하세요:',
     },
     },
+    colorSubstitution: {
+      rowBadge: '슬롯 {{slot}}이(가) {{got}}(으)로 출력되었습니다. 파일은 {{wanted}}을(를) 요구합니다',
+      rowTooltip: '이 슬롯이 사용한 스풀의 색상이 파일의 색상과 다릅니다. 해당 색상의 스풀이 장착되어 있지 않았거나 슬롯을 직접 선택했습니다.',
+    },
     slicerAmsMapping: {
     slicerAmsMapping: {
       rowBadge: '이 프린터용으로 저장된 AMS 슬롯',
       rowBadge: '이 프린터용으로 저장된 AMS 슬롯',
       rowTooltip: '이 아카이브에는 슬라이서가 선택한 정확한 AMS 슬롯이 이 항목의 대상 프린터용으로 저장되어 있습니다. 해당 프린터에서 재인쇄하면 유형과 색상으로 다시 맞추는 대신 그 트레이를 재사용할 수 있습니다.',
       rowTooltip: '이 아카이브에는 슬라이서가 선택한 정확한 AMS 슬롯이 이 항목의 대상 프린터용으로 저장되어 있습니다. 해당 프린터에서 재인쇄하면 유형과 색상으로 다시 맞추는 대신 그 트레이를 재사용할 수 있습니다.',

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

@@ -1429,6 +1429,10 @@ export default {
       confirmTitle: 'Filament niet geladen',
       confirmTitle: 'Filament niet geladen',
       confirmIntro: 'De printer heeft geen sleuf met dit filament. Laad het en druk opnieuw op Start, of druk toch af:',
       confirmIntro: 'De printer heeft geen sleuf met dit filament. Laad het en druk opnieuw op Start, of druk toch af:',
     },
     },
+    colorSubstitution: {
+      rowBadge: 'Slot {{slot}} printte in {{got}}, het bestand vraagt om {{wanted}}',
+      rowTooltip: 'De spoel waarmee dit slot printte heeft een andere kleur dan in het bestand: er was geen spoel in die kleur geladen, of het slot is met de hand gekozen.',
+    },
     slicerAmsMapping: {
     slicerAmsMapping: {
       rowBadge: 'AMS-sleuven opgeslagen voor deze printer',
       rowBadge: 'AMS-sleuven opgeslagen voor deze printer',
       rowTooltip: 'Dit archief bevat de exacte AMS-sleuven die de slicer heeft gekozen, opgeslagen voor de printer waarop dit item is gericht. Bij opnieuw afdrukken op deze printer kunnen die trays worden hergebruikt in plaats van opnieuw op type en kleur te matchen.',
       rowTooltip: 'Dit archief bevat de exacte AMS-sleuven die de slicer heeft gekozen, opgeslagen voor de printer waarop dit item is gericht. Bij opnieuw afdrukken op deze printer kunnen die trays worden hergebruikt in plaats van opnieuw op type en kleur te matchen.',

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

@@ -1417,6 +1417,10 @@ export default {
       confirmTitle: 'Filamento nao carregado',
       confirmTitle: 'Filamento nao carregado',
       confirmIntro: 'A impressora nao tem nenhuma bandeja com este filamento. Carregue-o e pressione Iniciar novamente, ou imprima mesmo assim:',
       confirmIntro: 'A impressora nao tem nenhuma bandeja com este filamento. Carregue-o e pressione Iniciar novamente, ou imprima mesmo assim:',
     },
     },
+    colorSubstitution: {
+      rowBadge: 'O slot {{slot}} imprimiu em {{got}}, o arquivo pede {{wanted}}',
+      rowTooltip: 'O carretel usado por este slot é de uma cor diferente da do arquivo: nenhum carretel dessa cor estava carregado ou o slot foi escolhido manualmente.',
+    },
     slicerAmsMapping: {
     slicerAmsMapping: {
       rowBadge: 'Slots AMS salvos para esta impressora',
       rowBadge: 'Slots AMS salvos para esta impressora',
       rowTooltip: 'Este arquivo mantém os slots AMS exatos escolhidos pelo fatiador, salvos para a impressora deste item. Uma reimpressão nela pode reutilizar esses carretéis em vez de casar novamente por tipo e cor.',
       rowTooltip: 'Este arquivo mantém os slots AMS exatos escolhidos pelo fatiador, salvos para a impressora deste item. Uma reimpressão nela pode reutilizar esses carretéis em vez de casar novamente por tipo e cor.',

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

@@ -1368,6 +1368,10 @@ export default {
       confirmTitle: "Филамент не загружен",
       confirmTitle: "Филамент не загружен",
       confirmIntro: "На принтере нет лотка с этим филаментом. Загрузите его и снова нажмите «Старт» или всё равно запустите печать:",
       confirmIntro: "На принтере нет лотка с этим филаментом. Загрузите его и снова нажмите «Старт» или всё равно запустите печать:",
     },
     },
+    colorSubstitution: {
+      rowBadge: 'Слот {{slot}} печатал цветом {{got}}, а файл требует {{wanted}}',
+      rowTooltip: 'Катушка, с которой печатал этот слот, другого цвета, чем в файле: катушки этого цвета не было загружено, или слот выбран вручную.',
+    },
     slicerAmsMapping: {
     slicerAmsMapping: {
       rowBadge: "Ячейки AMS сохранены для этого принтера",
       rowBadge: "Ячейки AMS сохранены для этого принтера",
       rowTooltip: "У этого архива сохранены точные ячейки AMS, выбранные слайсером, — для принтера, на который нацелено это задание. Повторная печать на нём может использовать те же катушки вместо повторного подбора по типу и цвету.",
       rowTooltip: "У этого архива сохранены точные ячейки AMS, выбранные слайсером, — для принтера, на который нацелено это задание. Повторная печать на нём может использовать те же катушки вместо повторного подбора по типу и цвету.",

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

@@ -1429,6 +1429,10 @@ export default {
       confirmTitle: 'Filament inte laddat',
       confirmTitle: 'Filament inte laddat',
       confirmIntro: 'Skrivaren har inget fack med detta filament. Ladda det och tryck på Start igen, eller skriv ut ändå:',
       confirmIntro: 'Skrivaren har inget fack med detta filament. Ladda det och tryck på Start igen, eller skriv ut ändå:',
     },
     },
+    colorSubstitution: {
+      rowBadge: 'Fack {{slot}} skrev ut i {{got}}, filen kräver {{wanted}}',
+      rowTooltip: 'Spolen som facket skrev ut från har en annan färg än den i filen: ingen spole i den färgen var laddad, eller så valdes facket för hand.',
+    },
     slicerAmsMapping: {
     slicerAmsMapping: {
       rowBadge: 'AMS-fack sparade för denna skrivare',
       rowBadge: 'AMS-fack sparade för denna skrivare',
       rowTooltip: 'Detta arkiv innehåller de exakta AMS-fack som slicer-programmet valde, sparade för den skrivare denna post riktar sig till. En omutskrift på den kan återanvända dessa brickor istället för att matcha igen efter typ och färg.',
       rowTooltip: 'Detta arkiv innehåller de exakta AMS-fack som slicer-programmet valde, sparade för den skrivare denna post riktar sig till. En omutskrift på den kan återanvända dessa brickor istället för att matcha igen efter typ och färg.',

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

@@ -1419,6 +1419,10 @@ export default {
       confirmTitle: 'Filament yüklü değil',
       confirmTitle: 'Filament yüklü değil',
       confirmIntro: 'Yazıcıda bu filamente sahip bir yuva yok. Yükleyip yeniden Başlat\'a basın veya yine de yazdırın:',
       confirmIntro: 'Yazıcıda bu filamente sahip bir yuva yok. Yükleyip yeniden Başlat\'a basın veya yine de yazdırın:',
     },
     },
+    colorSubstitution: {
+      rowBadge: 'Yuva {{slot}} {{got}} renginde bastı, dosya {{wanted}} istiyor',
+      rowTooltip: 'Bu yuvanın kullandığı makaranın rengi dosyadakinden farklı: o renkte makara takılı değildi ya da yuva elle seçildi.',
+    },
     slicerAmsMapping: {
     slicerAmsMapping: {
       rowBadge: 'Bu yazıcı için kaydedilen AMS yuvaları',
       rowBadge: 'Bu yazıcı için kaydedilen AMS yuvaları',
       rowTooltip: 'Bu arşiv, dilimleyicinin seçtiği tam AMS yuvalarını bu öğenin hedeflediği yazıcı için saklar. O yazıcıda yeniden yazdırma, tür ve renge göre yeniden eşleştirmek yerine bu makaraları yeniden kullanabilir.',
       rowTooltip: 'Bu arşiv, dilimleyicinin seçtiği tam AMS yuvalarını bu öğenin hedeflediği yazıcı için saklar. O yazıcıda yeniden yazdırma, tür ve renge göre yeniden eşleştirmek yerine bu makaraları yeniden kullanabilir.',

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

@@ -1428,6 +1428,10 @@ export default {
       confirmTitle: "Філамент не завантажено",
       confirmTitle: "Філамент не завантажено",
       confirmIntro: "На принтері немає лотка з цим філаментом. Завантажте його й знову натисніть «Старт» або все одно запустіть друк:",
       confirmIntro: "На принтері немає лотка з цим філаментом. Завантажте його й знову натисніть «Старт» або все одно запустіть друк:",
     },
     },
+    colorSubstitution: {
+      rowBadge: 'Слот {{slot}} друкував кольором {{got}}, а файл вимагає {{wanted}}',
+      rowTooltip: 'Котушка, з якої друкував цей слот, іншого кольору, ніж у файлі: котушки цього кольору не було завантажено, або слот обрано вручну.',
+    },
     slicerAmsMapping: {
     slicerAmsMapping: {
       rowBadge: "Слоти AMS збережено для цього принтера",
       rowBadge: "Слоти AMS збережено для цього принтера",
       rowTooltip: "Цей запис містить точні слоти AMS, вибрані слайсером, збережені для принтера, на який націлено це завдання. Повторний друк на ньому може використати ті самі котушки замість повторного добору за типом і кольором.",
       rowTooltip: "Цей запис містить точні слоти AMS, вибрані слайсером, збережені для принтера, на який націлено це завдання. Повторний друк на ньому може використати ті самі котушки замість повторного добору за типом і кольором.",

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

@@ -1417,6 +1417,10 @@ export default {
       confirmTitle: '耗材未装载',
       confirmTitle: '耗材未装载',
       confirmIntro: '打印机上没有装有此耗材的料槽。请装载后再次点击开始,或仍要打印:',
       confirmIntro: '打印机上没有装有此耗材的料槽。请装载后再次点击开始,或仍要打印:',
     },
     },
+    colorSubstitution: {
+      rowBadge: '槽位 {{slot}} 使用 {{got}} 打印,文件要求 {{wanted}}',
+      rowTooltip: '此槽位使用的耗材颜色与文件中的不同:未装入该颜色的耗材,或槽位为手动选择。',
+    },
     slicerAmsMapping: {
     slicerAmsMapping: {
       rowBadge: '已为此打印机保存 AMS 槽位',
       rowBadge: '已为此打印机保存 AMS 槽位',
       rowTooltip: '此存档保留了切片软件选择的确切 AMS 槽位,并为该项目的目标打印机保存。在该打印机上重新打印时,可复用这些料盘,而不必再按类型和颜色重新匹配。',
       rowTooltip: '此存档保留了切片软件选择的确切 AMS 槽位,并为该项目的目标打印机保存。在该打印机上重新打印时,可复用这些料盘,而不必再按类型和颜色重新匹配。',

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

@@ -1417,6 +1417,10 @@ export default {
       confirmTitle: '線材未裝載',
       confirmTitle: '線材未裝載',
       confirmIntro: '印表機上沒有裝有此線材的料槽。請裝載後再次按下開始,或仍要列印:',
       confirmIntro: '印表機上沒有裝有此線材的料槽。請裝載後再次按下開始,或仍要列印:',
     },
     },
+    colorSubstitution: {
+      rowBadge: '槽位 {{slot}} 使用 {{got}} 列印,檔案要求 {{wanted}}',
+      rowTooltip: '此槽位使用的線材顏色與檔案中的不同:未裝入該顏色的線材,或槽位為手動選擇。',
+    },
     slicerAmsMapping: {
     slicerAmsMapping: {
       rowBadge: '已為此印表機儲存 AMS 槽位',
       rowBadge: '已為此印表機儲存 AMS 槽位',
       rowTooltip: '此封存保留了切片軟體選擇的確切 AMS 槽位,並為此項目的目標印表機儲存。在該印表機上重新列印時,可重複使用這些料盤,而不必再依類型與顏色重新比對。',
       rowTooltip: '此封存保留了切片軟體選擇的確切 AMS 槽位,並為此項目的目標印表機儲存。在該印表機上重新列印時,可重複使用這些料盤,而不必再依類型與顏色重新比對。',

+ 8 - 0
frontend/src/pages/QueuePage.tsx

@@ -84,6 +84,7 @@ import { useToast } from '../contexts/ToastContext';
 import { useAuth } from '../contexts/AuthContext';
 import { useAuth } from '../contexts/AuthContext';
 import { QueueStatsBar } from '../components/QueueStatsBar';
 import { QueueStatsBar } from '../components/QueueStatsBar';
 import { CompactHistoryRow } from '../components/CompactHistoryRow';
 import { CompactHistoryRow } from '../components/CompactHistoryRow';
+import { ColorSubstitutionNotes } from '../components/ColorSubstitutionNotes';
 import { QueueTimelineView } from '../components/QueueTimelineView';
 import { QueueTimelineView } from '../components/QueueTimelineView';
 import { compareQueueOrder } from '../utils/queueOrder';
 import { compareQueueOrder } from '../utils/queueOrder';
 import { BatchOrdersView } from '../components/BatchOrdersView';
 import { BatchOrdersView } from '../components/BatchOrdersView';
@@ -1076,6 +1077,13 @@ function SortableQueueItem({
             </p>
             </p>
           )}
           )}
 
 
+          {/* Sent with a spool of another colour than the file asks for (#3279). */}
+          <ColorSubstitutionNotes
+            substitutions={item.color_substitutions}
+            t={t}
+            className="text-[10px] sm:text-xs mt-1.5 sm:mt-2"
+          />
+
           {/* Archive carries the slicer's own live-resolved AMS-slot pick
           {/* Archive carries the slicer's own live-resolved AMS-slot pick
               (extra_data.slicer_ams_mapping) — reprints of this archive reuse
               (extra_data.slicer_ams_mapping) — reprints of this archive reuse
               the exact physical spool instead of re-deriving one. */}
               the exact physical spool instead of re-deriving one. */}

Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/ArchivesPage-8vBHyzCo.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/CameraPage-D_d4E29T.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/FileManagerPage-_qdAzVsc.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/InventoryPage-8RhZkDKr.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/Layout-F1iGI_1j.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/ModelSourcesPage-BW3KDLUO.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/PhotoGalleryModal-CnAVA-Fw.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/PrintersPage-DfVKE0S_.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/QueuePage-CKosToJ2.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/QueuePage-DFS4se7g.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/SettingsPage-CWK1_dBW.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/SliceModal-C09WTp7q.js


Fichier diff supprimé car celui-ci est trop grand
+ 1 - 1
static/assets/SpoolBuddySettingsPage-DMcAk9_a.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/de-CP5J7a-n.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/de-D39JI_8d.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/es-BHL-6pV1.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/es-ucd2Rt5d.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/fr-CpEo28WL.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/fr-IxLLv2H_.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/index-BSJ3-ebL.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/it-CFRvX4yY.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/it-DKgH5LEK.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/ja-C4JWWL34.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/ja-vehaWIqv.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/ko-CREU6ze4.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/ko-CbTWUFFi.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/locationSensorDefaults-af_GVFMj.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/nl-CixN_KdW.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/nl-DBMExM6i.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/pt-BR-DBfeh188.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/pt-BR-DqnpZVu5.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/ru-Bw5Xt0Zp.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/ru-rVUUdG_R.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/sv-Cqpp_hoE.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/sv-DQBLU3Nr.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/tr-C3iyyu_Z.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/tr-Fokmo5P7.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/uk-DO3--z4v.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/uk-DYl3Aek2.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/zh-CN-D7IpC6DP.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/zh-CN-YcuBUFHM.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/zh-TW-DARWgtcC.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/zh-TW-Dq_Gi0kY.js


+ 2 - 2
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-WCWK8Sj1.js"></script>
+    <script type="module" crossorigin src="/assets/index-BSJ3-ebL.js"></script>
     <link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-aKtaBQYM.js">
     <link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-aKtaBQYM.js">
     <link rel="modulepreload" crossorigin href="/assets/client-DSw2TdH4.js">
     <link rel="modulepreload" crossorigin href="/assets/client-DSw2TdH4.js">
     <link rel="modulepreload" crossorigin href="/assets/react-dom-PmRWqOqI.js">
     <link rel="modulepreload" crossorigin href="/assets/react-dom-PmRWqOqI.js">
@@ -120,7 +120,7 @@
     <link rel="modulepreload" crossorigin href="/assets/circle-minus-CpiTIx_j.js">
     <link rel="modulepreload" crossorigin href="/assets/circle-minus-CpiTIx_j.js">
     <link rel="modulepreload" crossorigin href="/assets/ConnectionDiagnostic-jD6ObGRm.js">
     <link rel="modulepreload" crossorigin href="/assets/ConnectionDiagnostic-jD6ObGRm.js">
     <link rel="modulepreload" crossorigin href="/assets/plateCheck-fqh7_usj.js">
     <link rel="modulepreload" crossorigin href="/assets/plateCheck-fqh7_usj.js">
-    <link rel="modulepreload" crossorigin href="/assets/Layout-GVz6JdbD.js">
+    <link rel="modulepreload" crossorigin href="/assets/Layout-F1iGI_1j.js">
     <link rel="stylesheet" crossorigin href="/assets/index-Cs9GKX3n.css">
     <link rel="stylesheet" crossorigin href="/assets/index-Cs9GKX3n.css">
   </head>
   </head>
   <body>
   <body>

Certains fichiers n'ont pas été affichés car il y a eu trop de fichiers modifiés dans ce diff