Просмотр исходного кода

Show the spool that is in the AMS slot, not the one that was

Pull a Bambu ABS Orange out of A1, put a PLA Matte Dark Blue in, and the
slot card still read "Bambu ABS" against the new colour until the page
was reloaded.

Three things stood between the swap and a correct card.

The RFID auto-assign rewrites the slot's slot_preset_mappings row and
then broadcast an event that refreshed everything except the query that
reads it. Only the manual assign path invalidated that one.

Those queries then sat behind the 3s cascade debounce, which exists for
print completion, where one event fans out across half the app. A swap
touches one slot and the user is standing at the printer looking at the
card; worse, the timer restarts on every further event, so a busy moment
could defer it indefinitely. Slot changes now invalidate immediately.

And the card trusted the stored preset over live telemetry outright.
That priority is why a hand-picked preset name stays on a slot, but it
also let a cached row outrank what the printer was reporting. The row is
now skipped when it names a different official Bambu filament than the
tray does, so the card is right from the status push alone. User and
local presets carry ids that genuinely cannot be compared and are left
exactly as they were.

Spoolman mode was the worse half of the same bug: its AMS sync writes
the same row but announced nothing at all, so there was no event to
refresh on. It now reports each slot it changed or cleared.
maziggy 1 неделя назад
Родитель
Сommit
7363d5fd33

+ 1 - 0
CHANGELOG.md

@@ -26,6 +26,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **The Windows installer build is split in two so a signing request can wait for a human (SignPath Foundation)** — Release tags are Authenticode-signed through the SignPath Foundation OSS programme, and the production certificate does not sign on demand the way the self-signed test certificate does: every request has to be approved by hand in the SignPath UI, because the Foundation verifies what is being signed and which build it came from. The submitting action waits for that approval with a default timeout of 600 seconds, which is ample when the test policy approves automatically in seconds and far too short once the wait is a person noticing a tag went out. A tag pushed at night would have failed the run ten minutes later with the installer already compiled and thrown away. The compile now ends in its own job that uploads the unsigned artifact and stops; a second job downloads it, signs it, and does the release-facing work, with the wait raised to an hour. Because the artifact is uploaded before the wait begins and is addressed by id, a missed approval window is recovered by re-running the second job alone rather than rebuilding the installer — which is the reason to separate them rather than simply raise the timeout in place. The second job runs for unsigned builds too, so the daily prereleases that are deliberately left unsigned to preserve the signing quota keep going out through exactly one set of alias, artifact and release steps. The property that matters is unchanged and now recorded next to the steps that depend on it: none of the alias, upload or release-attach steps carry `always()`, so GitHub skips all three when signing fails or times out, and an unsigned `.exe` cannot reach a release. Nothing about the signed output changes, and the restructure behaves identically under the test policy — the request simply completes immediately instead of waiting — so it can be proven green before the production certificate arrives.
 - **The Windows installer build is split in two so a signing request can wait for a human (SignPath Foundation)** — Release tags are Authenticode-signed through the SignPath Foundation OSS programme, and the production certificate does not sign on demand the way the self-signed test certificate does: every request has to be approved by hand in the SignPath UI, because the Foundation verifies what is being signed and which build it came from. The submitting action waits for that approval with a default timeout of 600 seconds, which is ample when the test policy approves automatically in seconds and far too short once the wait is a person noticing a tag went out. A tag pushed at night would have failed the run ten minutes later with the installer already compiled and thrown away. The compile now ends in its own job that uploads the unsigned artifact and stops; a second job downloads it, signs it, and does the release-facing work, with the wait raised to an hour. Because the artifact is uploaded before the wait begins and is addressed by id, a missed approval window is recovered by re-running the second job alone rather than rebuilding the installer — which is the reason to separate them rather than simply raise the timeout in place. The second job runs for unsigned builds too, so the daily prereleases that are deliberately left unsigned to preserve the signing quota keep going out through exactly one set of alias, artifact and release steps. The property that matters is unchanged and now recorded next to the steps that depend on it: none of the alias, upload or release-attach steps carry `always()`, so GitHub skips all three when signing fails or times out, and an unsigned `.exe` cannot reach a release. Nothing about the signed output changes, and the restructure behaves identically under the test policy — the request simply completes immediately instead of waiting — so it can be proven green before the production certificate arrives.
 
 
 ### Fixed
 ### Fixed
+- **Swapping a spool left the previous spool's preset name on the AMS slot card** — pull a Bambu ABS Orange out of A1, put a PLA Matte Dark Blue in, and the card still read "Bambu ABS" against the new colour. The backend had it right all along: the RFID auto-assign rewrites the slot's stored preset the moment the tag is read. The browser simply never refetched it. The slot card reads that stored preset ahead of the filament id the printer is reporting, so one cached row outranked correct data arriving over the WebSocket — and because every other field on the card (colour, material, fill, K value) rides the status push and updated instantly, it surfaced as a single wrong line rather than an obviously stale card. The manual assign path already refreshed it; the RFID path did not. Spoolman mode was the worse half of the same bug: its AMS sync writes that same row but announced nothing at all, so there was no event to refresh on — it now reports each slot it changed or cleared. Two further changes make the card right without waiting on any of that: the slot's queries no longer sit behind the 3-second cascade debounce meant for print completion (a swap touches one slot, and any further event restarted that timer), and the card now ignores a stored preset whose filament id disagrees with what the printer reports in the slot, so the correct name is on screen from the status push alone. A hand-picked preset name still wins wherever the stored row and the slot agree, and a user or local preset — whose ids genuinely cannot be compared — is untouched.
 - **A print that could not fetch its own 3MF could be charged another plate's filament (#2957, reported by @doncaruana)** — when the source file is missing, the usage tracker looks for a replacement in the library or in a previous archive and matched on the filename stem alone. That is far weaker evidence than it looks: Bambu Studio writes the printer-side filename from the project's `Title` metadata, so every plate of a project arrives on the printer under one name however the file was renamed on disk. The reporter's single-filament job was handed a previous archive's three-filament plate and three spools were debited for material they never extruded, with nothing on the archive to say the numbers were someone else's. A candidate is now refused when it holds a different plate than the one running, and an all-plates export is refused unless it actually contains that plate — previously the plate was looked for later, found missing, and every filament in the file was summed onto one plate's print. Where the printer echoes only the 3MF filename and the plate cannot be known at all, which is the reporter's own firmware, the candidate is still accepted on its name and a warning now says so rather than the deduction happening silently.
 - **A print that could not fetch its own 3MF could be charged another plate's filament (#2957, reported by @doncaruana)** — when the source file is missing, the usage tracker looks for a replacement in the library or in a previous archive and matched on the filename stem alone. That is far weaker evidence than it looks: Bambu Studio writes the printer-side filename from the project's `Title` metadata, so every plate of a project arrives on the printer under one name however the file was renamed on disk. The reporter's single-filament job was handed a previous archive's three-filament plate and three spools were debited for material they never extruded, with nothing on the archive to say the numbers were someone else's. A candidate is now refused when it holds a different plate than the one running, and an all-plates export is refused unless it actually contains that plate — previously the plate was looked for later, found missing, and every filament in the file was summed onto one plate's print. Where the printer echoes only the 3MF filename and the plate cannot be known at all, which is the reporter's own firmware, the candidate is still accepted on its name and a warning now says so rather than the deduction happening silently.
 - **A slow-but-healthy 3MF download was cut off at 30 seconds (#2957, reported by @doncaruana)** — `ftp_timeout` is handed to every download as *both* the socket inactivity timeout and the whole-transfer deadline, which makes its default a cap on how big a file a printer is allowed to serve. The reporter measured the same 5.4 MB 3MF at 45 s off a worn P1S SD card and 25 s off a new one, and a 15.15 MB 3MF at 105 s; an older 7.8 MB archive in his logs survived only because the transfer happened to finish inside the retry grace. None of those links were broken — they were slow, which is what the inactivity timeout exists to tell apart. The total deadline now follows the size the printer reports for the file, against the same pessimistic 25 KB/s floor the upload path has used since #2529. The extension is granted only once the printer has answered `SIZE`, so a printer that is not answering at all still fails on schedule and the executor queue wait #2572 bounded is unaffected, and it is capped at five minutes because the print-start handler holds a pooled database connection for the length of its 3MF hunt. A transfer that overruns even that stretched deadline is not retried, for the reason an overrunning upload has not been since #2529: the retry would spend another full deadline reaching the same conclusion.
 - **A slow-but-healthy 3MF download was cut off at 30 seconds (#2957, reported by @doncaruana)** — `ftp_timeout` is handed to every download as *both* the socket inactivity timeout and the whole-transfer deadline, which makes its default a cap on how big a file a printer is allowed to serve. The reporter measured the same 5.4 MB 3MF at 45 s off a worn P1S SD card and 25 s off a new one, and a 15.15 MB 3MF at 105 s; an older 7.8 MB archive in his logs survived only because the transfer happened to finish inside the retry grace. None of those links were broken — they were slow, which is what the inactivity timeout exists to tell apart. The total deadline now follows the size the printer reports for the file, against the same pessimistic 25 KB/s floor the upload path has used since #2529. The extension is granted only once the printer has answered `SIZE`, so a printer that is not answering at all still fails on schedule and the executor queue wait #2572 bounded is unaffected, and it is capped at five minutes because the print-start handler holds a pooled database connection for the length of its 3MF hunt. A transfer that overruns even that stretched deadline is not retried, for the reason an overrunning upload has not been since #2529: the retry would spend another full deadline reaching the same conclusion.
 - **Bambuddy could run two heavy FTPS transfers against one printer at once (#2957, reported by @doncaruana)** — the reporter watched Bambu Studio itself lose its connection to a P1S while Bambuddy pulled a 12 MB 3MF, and a later log caught two Bambuddy downloads of the *same* 5,250,969-byte file overlapping during print start. A P1S at that moment is already serving the print off the same SD card and answering MQTT. Downloads now take turns per printer, the way uploads have since #2529. The gate is deliberately soft — a download that cannot have it within 30 seconds proceeds anyway, because a print losing its 3MF to queueing would be worse than the contention, and the printer file browser stays outside it entirely so a 3MF preview never waits out somebody else's multi-gigabyte selection — and it is now meaningful at all: the 90-second cap on a multi-path lookup used to return while its worker thread kept walking the remaining paths, still holding the printer's socket, so the walk is cancelled and waited out before the printer is handed on.
 - **Bambuddy could run two heavy FTPS transfers against one printer at once (#2957, reported by @doncaruana)** — the reporter watched Bambu Studio itself lose its connection to a P1S while Bambuddy pulled a 12 MB 3MF, and a later log caught two Bambuddy downloads of the *same* 5,250,969-byte file overlapping during print start. A P1S at that moment is already serving the print off the same SD card and answering MQTT. Downloads now take turns per printer, the way uploads have since #2529. The gate is deliberately soft — a download that cannot have it within 30 seconds proceeds anyway, because a print losing its 3MF to queueing would be worse than the contention, and the printer file browser stays outside it entirely so a 3MF preview never waits out somebody else's multi-gigabyte selection — and it is now meaningful at all: the 90-second cap on a multi-path lookup used to return while its worker thread kept walking the remaining paths, still holding the printer's socket, so the walk is cancelled and waited out before the printer is handed on.

+ 20 - 0
backend/app/main.py

@@ -2774,6 +2774,26 @@ async def on_ams_change(printer_id: int, ams_data: list):
                 except Exception as e:
                 except Exception as e:
                     await db.rollback()
                     await db.rollback()
                     logger.error("Error persisting Spoolman slot assignments for printer %s: %s", printer_id, e)
                     logger.error("Error persisting Spoolman slot assignments for printer %s: %s", printer_id, e)
+                else:
+                    # Tell open browsers the slot changed. This loop rewrites
+                    # slot_preset_mappings via upsert_slot_preset_for_spoolman_spool
+                    # above, and the AMS slot card reads that row ahead of the
+                    # live tray_info_idx -- so with no event the card keeps
+                    # showing the previous spool's preset name. Internal mode
+                    # raises spool_auto_assigned for the same reason; this loop
+                    # broadcast nothing at all, which made Spoolman mode the
+                    # worse half of the same bug. On the else branch so a
+                    # failed commit stays silent and a broadcast failure cannot
+                    # roll back rows that are already committed.
+                    for ams_id, tray_id, *_ in (*slot_changes, *empty_slots):
+                        await ws_manager.broadcast(
+                            {
+                                "type": "spool_assignment_changed",
+                                "printer_id": printer_id,
+                                "ams_id": ams_id,
+                                "tray_id": tray_id,
+                            }
+                        )
 
 
     except Exception as e:
     except Exception as e:
         logging.getLogger(__name__).error("Spoolman AMS sync failed for printer %s: %s", printer_id, e)
         logging.getLogger(__name__).error("Spoolman AMS sync failed for printer %s: %s", printer_id, e)

+ 163 - 0
backend/tests/integration/test_spoolman_ams_sync_broadcast.py

@@ -0,0 +1,163 @@
+"""Spoolman's AMS sync has to tell the browser the slot changed.
+
+Swap a spool and the AMS slot card keeps showing the *previous* spool's preset
+name. The card reads ``slot_preset_mappings.preset_name`` ahead of the live
+``tray_info_idx``, so a cached row wins over correct data pushed over the
+socket — and everything else on the card rides the status push, which is why it
+surfaces as one wrong line rather than an obviously stale card.
+
+Built-in inventory raised ``spool_auto_assigned`` for this (the frontend simply
+forgot to invalidate ``slotPresets`` on it). This loop raised nothing at all,
+even though it rewrites the very same row through
+``upsert_slot_preset_for_spoolman_spool`` — so in Spoolman mode there was no
+event to hang an invalidation on, and the stale name stood until an unrelated
+refetch.
+
+An emptied slot counts: its ``spoolman_slot_assignments`` row is deleted here,
+and a card still drawing the removed spool is the same defect.
+"""
+
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+from httpx import AsyncClient
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.models.settings import Settings
+from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
+
+
+def _status(ams_data):
+    status = MagicMock()
+    status.raw_data = {"ams": ams_data, "vt_tray": []}
+    status.gcode_state = "IDLE"
+    return status
+
+
+def _tray(ams_id: int, tray_id: int):
+    """What ``client.parse_ams_tray`` hands back for an occupied slot."""
+    tray = MagicMock()
+    tray.ams_id = ams_id
+    tray.tray_id = tray_id
+    tray.tray_uuid = "EB335968299543078925C71D83DA3864"
+    tray.tag_uid = "7757EF0100000100"
+    tray.tray_info_idx = "GFA01"
+    tray.tray_type = "PLA"
+    tray.tray_sub_brands = "PLA Matte"
+    tray.tray_color = "042F56FF"
+    return tray
+
+
+async def _run_ams_change(printer_id: int, ams_data: list, *, parsed):
+    """Drive ``on_ams_change`` with Spoolman standing in for the real server.
+
+    ``parsed`` maps (ams_id, tray_id) to a parsed tray or ``None`` (empty slot),
+    which is the only thing that decides whether the sync treats the slot as
+    occupied or cleared.
+    """
+    from backend.app.main import on_ams_change
+
+    spoolman = MagicMock()
+    spoolman.health_check = AsyncMock(return_value=True)
+    spoolman.get_spools = AsyncMock(return_value=[])
+    spoolman.parse_ams_tray = MagicMock(
+        side_effect=lambda ams_id, tray_data: parsed.get((ams_id, int(tray_data.get("id", 0))))
+    )
+    spoolman.sync_ams_tray = AsyncMock(return_value={"id": 4242})
+
+    status = _status(ams_data)
+    with (
+        patch("backend.app.main.printer_manager") as pm_main,
+        patch("backend.app.services.printer_manager.printer_manager") as pm_inv,
+        patch("backend.app.main.mqtt_relay") as relay,
+        patch("backend.app.main.ws_manager") as ws,
+        patch("backend.app.main.get_spoolman_client", AsyncMock(return_value=spoolman)),
+        patch(
+            "backend.app.services.slot_preset_writer.upsert_slot_preset_for_spoolman_spool",
+            AsyncMock(),
+        ),
+    ):
+        pm_main.get_printer.return_value = MagicMock(name="P", serial_number="SER")
+        pm_main.get_status.return_value = status
+        pm_main.get_client.return_value = MagicMock()
+        pm_main.get_model.return_value = "X1C"
+        pm_inv.get_status.return_value = status
+        pm_inv.get_client.return_value = MagicMock()
+        relay.on_ams_change = AsyncMock()
+        ws.send_printer_status = AsyncMock()
+        ws.broadcast = AsyncMock()
+        await on_ams_change(printer_id, ams_data)
+        return ws.broadcast, spoolman
+
+
+def _slot_events(broadcast) -> set[tuple[int, int]]:
+    """(ams_id, tray_id) of every assignment-changed event that went out."""
+    return {
+        (call.args[0]["ams_id"], call.args[0]["tray_id"])
+        for call in broadcast.call_args_list
+        if call.args and call.args[0].get("type") == "spool_assignment_changed"
+    }
+
+
+async def _enable_spoolman(db: AsyncSession) -> None:
+    db.add(Settings(key="spoolman_enabled", value="true"))
+    db.add(Settings(key="spoolman_url", value="http://spoolman.invalid:7912"))
+    await db.commit()
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_a_synced_slot_is_broadcast(async_client: AsyncClient, printer_factory, db_session: AsyncSession):
+    printer = await printer_factory(name="H2C")
+    await _enable_spoolman(db_session)
+
+    broadcast, spoolman = await _run_ams_change(
+        printer.id,
+        [{"id": 0, "tray": [{"id": 0, "tray_type": "PLA", "tray_color": "042F56FF", "state": 11}]}],
+        parsed={(0, 0): _tray(0, 0)},
+    )
+
+    spoolman.sync_ams_tray.assert_awaited()
+    assert (0, 0) in _slot_events(broadcast)
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_an_emptied_slot_is_broadcast(async_client: AsyncClient, printer_factory, db_session: AsyncSession):
+    """The row is deleted here; a card still drawing the removed spool is the
+    same bug seen from the other side."""
+    printer = await printer_factory(name="H2C")
+    await _enable_spoolman(db_session)
+    db_session.add(SpoolmanSlotAssignment(printer_id=printer.id, ams_id=0, tray_id=1, spoolman_spool_id=7))
+    await db_session.commit()
+
+    broadcast, _ = await _run_ams_change(
+        printer.id,
+        [{"id": 0, "tray": [{"id": 1}]}],
+        parsed={(0, 1): None},
+    )
+
+    assert (0, 1) in _slot_events(broadcast)
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_nothing_is_broadcast_when_no_slot_changed(
+    async_client: AsyncClient, printer_factory, db_session: AsyncSession
+):
+    """A push that changes no slot must stay quiet — this runs on every AMS
+    message, and an event per push would invalidate the browser's caches
+    continuously."""
+    printer = await printer_factory(name="H2C")
+    await _enable_spoolman(db_session)
+
+    broadcast, spoolman = await _run_ams_change(
+        printer.id,
+        [{"id": 0, "tray": []}],
+        parsed={},
+    )
+
+    # Guards the assertion below against passing because the sync bailed out
+    # early on a mis-set fixture rather than because it found nothing to say.
+    spoolman.get_spools.assert_awaited()
+    assert _slot_events(broadcast) == set()

+ 96 - 0
frontend/src/__tests__/hooks/useWebSocket.test.ts

@@ -470,6 +470,102 @@ describe('useWebSocket hook', () => {
       vi.unstubAllGlobals();
       vi.unstubAllGlobals();
     });
     });
 
 
+    /*
+     * Swapping a spool leaves the previous spool's preset name on the AMS slot
+     * card.
+     *
+     * The RFID auto-assign rewrites the slot's slot_preset_mappings row, and
+     * PrintersPage reads `slotPreset?.preset_name` *ahead of* the live
+     * tray_info_idx lookup -- so a cached row wins over correct data pushed
+     * over the socket. Everything else on the card rides the status push and
+     * updates instantly, which is why this surfaces as one wrong line rather
+     * than a stale card: pull a Bambu ABS Orange, insert a PLA Matte Dark
+     * Blue, and the card reads "Bambu ABS" against the new colour.
+     *
+     * `slotPresets` has a 2-minute staleTime and no refetch interval, so on a
+     * dashboard left open and focused nothing ever refetches it.
+     */
+    it('invalidates slot presets on spool_auto_assigned message', async () => {
+      vi.useFakeTimers();
+      const { useWebSocket } = await import('../../hooks/useWebSocket');
+
+      const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
+
+      renderHook(() => useWebSocket(), {
+        wrapper: createWrapper(queryClient),
+      });
+
+      const ws = await waitForWs();
+
+      act(() => {
+        ws.open();
+      });
+
+      act(() => {
+        ws.simulateMessage({
+          type: 'spool_auto_assigned',
+          printer_id: 7,
+          ams_id: 0,
+          tray_id: 0,
+          spool_id: 110,
+        });
+      });
+
+      // No timer advance: the user is standing at the printer looking at the
+      // card, so the slot's own queries must not wait out the 3s cascade
+      // debounce (which any further event would restart anyway).
+      expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['slotPresets'] });
+      expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['spool-assignments'] });
+
+      // The spool list is not on the card's critical path and stays debounced.
+      expect(invalidateSpy).not.toHaveBeenCalledWith({ queryKey: ['inventory-spools'] });
+      await act(async () => {
+        vi.advanceTimersByTime(5000);
+      });
+      expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['inventory-spools'] });
+
+      vi.useRealTimers();
+      vi.unstubAllGlobals();
+    });
+
+    /*
+     * Spoolman mode reaches the same slot_preset_mappings row through its own
+     * AMS sync, which raises spool_assignment_changed. Its slot rows live under
+     * a different query key, so the internal-mode key alone left that half of
+     * the UI on the previous spool.
+     */
+    it('invalidates both inventory modes on spool_assignment_changed message', async () => {
+      vi.useFakeTimers();
+      const { useWebSocket } = await import('../../hooks/useWebSocket');
+
+      const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
+
+      renderHook(() => useWebSocket(), {
+        wrapper: createWrapper(queryClient),
+      });
+
+      const ws = await waitForWs();
+
+      act(() => {
+        ws.open();
+      });
+
+      act(() => {
+        ws.simulateMessage({
+          type: 'spool_assignment_changed',
+          printer_id: 7,
+          ams_id: 0,
+          tray_id: 0,
+        });
+      });
+
+      expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['slotPresets'] });
+      expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['spool-assignments'] });
+      expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['spoolman-slot-assignments'] });
+
+      vi.useRealTimers();
+      vi.unstubAllGlobals();
+    });
     it('handles missing_spool_assignment message without error', async () => {
     it('handles missing_spool_assignment message without error', async () => {
       const { useWebSocket } = await import('../../hooks/useWebSocket');
       const { useWebSocket } = await import('../../hooks/useWebSocket');
 
 

+ 153 - 0
frontend/src/__tests__/pages/PrintersPageSlotPresetStale.test.tsx

@@ -0,0 +1,153 @@
+/**
+ * A swapped spool must not leave the previous spool's preset name on the card.
+ *
+ * `slot_preset_mappings` is fetched over REST and remembers what the slot was
+ * last configured with; the tray's own `tray_info_idx` arrives on the
+ * WebSocket. The display chain puts the stored row first -- that is what keeps
+ * a hand-picked preset name on a slot -- so between the swap and the row being
+ * refetched the card named the spool that had just been removed. Everything
+ * else on the card rides the status push and was already correct, which is why
+ * it read as one wrong line rather than a stale card.
+ *
+ * Verbatim from the report: a Bambu ABS Orange came out of A1, a PLA Matte
+ * Dark Blue went in, and the card still said "Bambu ABS".
+ */
+
+import { describe, it, expect, beforeEach } from 'vitest';
+import { screen, waitFor, fireEvent } from '@testing-library/react';
+import { render } from '../utils';
+import { PrintersPage } from '../../pages/PrintersPage';
+import { http, HttpResponse } from 'msw';
+import { server } from '../mocks/server';
+
+const mockPrinter = {
+  id: 1,
+  name: 'X1 Carbon',
+  ip_address: '192.168.1.100',
+  serial_number: '00M09A350100001',
+  access_code: '12345678',
+  model: 'X1C',
+  enabled: true,
+  nozzle_diameter: 0.4,
+  nozzle_type: 'hardened_steel',
+  location: 'Workshop',
+  auto_archive: true,
+  created_at: '2024-01-01T00:00:00Z',
+  updated_at: '2024-01-01T00:00:00Z',
+};
+
+/** The PLA Matte Dark Blue now in the slot, as the printer reports it. */
+const darkBlue = {
+  tray_color: '042F56FF',
+  tray_type: 'PLA',
+  tray_sub_brands: 'PLA Matte',
+  tray_id_name: 'A01-B3',
+  tray_info_idx: 'GFA01',
+  remain: 80,
+  k: 0.02,
+  cali_idx: null,
+  tag_uid: null,
+  tray_uuid: null,
+  nozzle_temp_min: 190,
+  nozzle_temp_max: 230,
+  drying_temp: null,
+  drying_time: null,
+  state: 11,
+};
+
+const status = {
+  connected: true,
+  state: 'IDLE',
+  progress: 0,
+  layer_num: 0,
+  total_layers: 0,
+  temperatures: { nozzle: 25, bed: 25, chamber: 25 },
+  remaining_time: 0,
+  filename: null,
+  wifi_signal: -50,
+  speed_level: 2,
+  vt_tray: [],
+  ams: [
+    {
+      id: 0,
+      humidity: 30,
+      temp: 25,
+      is_ams_ht: false,
+      serial_number: 'AMS00',
+      sw_ver: '1.0.0',
+      dry_time: 0,
+      dry_status: 0,
+      dry_sub_status: 0,
+      dry_sf_reason: [],
+      module_type: 'ams',
+      tray: [{ id: 0, ...darkBlue }],
+    },
+  ],
+};
+
+/** Hover-card visibility flips after an 80ms timeout — wait it out. */
+async function hoverFirstSlot() {
+  await waitFor(() => {
+    expect(screen.getAllByTestId('filament-slot').length).toBeGreaterThan(0);
+  });
+  fireEvent.mouseEnter(screen.getAllByTestId('filament-slot')[0]);
+}
+
+function serve(slotPresets: Record<number, unknown>) {
+  server.use(
+    http.get('/api/v1/printers/', () => HttpResponse.json([mockPrinter])),
+    http.get('/api/v1/queue/', () => HttpResponse.json([])),
+    http.get('/api/v1/printers/:id/status', () => HttpResponse.json(status)),
+    http.get('/api/v1/printers/:id/slot-presets', () => HttpResponse.json(slotPresets)),
+    http.post('/api/v1/cloud/filament-info', () =>
+      HttpResponse.json({
+        GFA01: { name: 'Bambu PLA Matte', k: null },
+        GFB00: { name: 'Bambu ABS', k: null },
+      }),
+    ),
+  );
+}
+
+describe('PrintersPage — a stale slot preset must not name the slot', () => {
+  beforeEach(() => {
+    server.use(http.get('/api/v1/printers/', () => HttpResponse.json([mockPrinter])));
+  });
+
+  it('ignores the row left by the spool that was removed', async () => {
+    serve({
+      0: { printer_id: 1, ams_id: 0, tray_id: 0, preset_id: 'GFSB00', preset_name: 'Bambu ABS', preset_source: 'cloud' },
+    });
+
+    render(<PrintersPage />);
+    await hoverFirstSlot();
+
+    // The live filament id names the slot instead.
+    await waitFor(() => {
+      expect(screen.getByText('Bambu PLA Matte')).toBeInTheDocument();
+    });
+    expect(screen.queryByText('Bambu ABS')).not.toBeInTheDocument();
+  });
+
+  it('still shows a hand-picked name while it describes what is in the slot', async () => {
+    // The whole reason the stored row outranks the catalog: this custom name
+    // must survive, and it is stored against the same official preset id the
+    // tray reports.
+    serve({
+      0: {
+        printer_id: 1,
+        ams_id: 0,
+        tray_id: 0,
+        preset_id: 'GFSA01',
+        preset_name: '# Bambu PLA Matte @BBL X1C 0.4 nozzle (Custom)',
+        preset_source: 'cloud',
+      },
+    });
+
+    render(<PrintersPage />);
+    await hoverFirstSlot();
+
+    await waitFor(() => {
+      expect(screen.getByText('# Bambu PLA Matte @BBL X1C 0.4 nozzle (Custom)')).toBeInTheDocument();
+    });
+  });
+});

+ 70 - 0
frontend/src/__tests__/utils/slotPresetDescribesTray.test.ts

@@ -0,0 +1,70 @@
+/**
+ * A stored slot preset must not outlive the spool it describes.
+ *
+ * `slot_preset_mappings` remembers what a slot was last configured with, and
+ * the AMS slot card puts that name ahead of the filament id the printer is
+ * reporting -- which is how a hand-picked name survives on the card. It also
+ * meant that pulling a Bambu ABS Orange and inserting a PLA Matte Dark Blue
+ * left "Bambu ABS" on the card against the new colour, because the row is
+ * fetched over REST while everything else arrives on the socket.
+ *
+ * The check is deliberately narrow. Official Bambu presets differ between the
+ * two id forms by one letter and can be compared; a user preset carries two
+ * genuinely unrelated ids and a local preset has no printer-side id at all, so
+ * neither can be judged here and both keep their name.
+ */
+
+import { describe, it, expect } from 'vitest';
+import { slotPresetDescribesTray } from '../../utils/amsHelpers';
+
+describe('slotPresetDescribesTray', () => {
+  describe('official Bambu presets, where the ids can be compared', () => {
+    it('accepts the setting_id / filament_id pair for one filament', () => {
+      expect(slotPresetDescribesTray('GFSA01', 'GFA01')).toBe(true);
+    });
+
+    it('rejects the row left behind by the previous spool', () => {
+      // The reported swap: ABS Basic out, PLA Matte in.
+      expect(slotPresetDescribesTray('GFSB00', 'GFA01')).toBe(false);
+    });
+
+    it('rejects a different filament in the same family', () => {
+      // PLA Matte row, PLA Basic roll -- same GFA prefix, still not the same
+      // filament, and the card would name the wrong one.
+      expect(slotPresetDescribesTray('GFSA01', 'GFA00')).toBe(false);
+    });
+
+    it('ignores the version suffix on either side', () => {
+      expect(slotPresetDescribesTray('GFSA01_07', 'GFA01')).toBe(true);
+      expect(slotPresetDescribesTray('GFSA01', 'GFA01_07')).toBe(true);
+    });
+
+    it('is case-insensitive, since the printer is not consistent about it', () => {
+      expect(slotPresetDescribesTray('gfsa01', 'GFA01')).toBe(true);
+    });
+  });
+
+  describe('rows that cannot be judged keep their name', () => {
+    it('a user preset, whose two ids are unrelated', () => {
+      // Verbatim from a live slot: ams_filament_setting sent
+      // setting_id=PFUSa3b8b0c664c142 and the tray reports tray_info_idx=P8a85d5a.
+      // Comparing those would blank a correctly configured slot.
+      expect(slotPresetDescribesTray('PFUSa3b8b0c664c142', 'P8a85d5a')).toBe(true);
+    });
+
+    it('a local preset, which has no printer-side id', () => {
+      expect(slotPresetDescribesTray('local_68', 'GFA00')).toBe(true);
+    });
+
+    it('a slot reporting no filament id at all', () => {
+      // Generic filament with no tag -- the case the stored row exists for.
+      expect(slotPresetDescribesTray('GFSA01', '')).toBe(true);
+      expect(slotPresetDescribesTray('GFSA01', null)).toBe(true);
+    });
+
+    it('no stored row', () => {
+      expect(slotPresetDescribesTray(null, 'GFA01')).toBe(true);
+      expect(slotPresetDescribesTray(undefined, undefined)).toBe(true);
+    });
+  });
+});

+ 27 - 6
frontend/src/hooks/useWebSocket.ts

@@ -288,6 +288,20 @@ export function useWebSocket() {
     }, 3000);
     }, 3000);
   }, [queryClient]);
   }, [queryClient]);
 
 
+  // Slot changes skip the cascade debounce above. That debounce exists for
+  // print completion, where one event fans out across half the app; a spool
+  // swap touches one slot and the user is standing at the printer looking at
+  // the card. Waiting 3s of quiet and then staggering the keys 500ms apart put
+  // several seconds of visibly wrong data on screen for no benefit, and the
+  // timer restarts on every further event, so a busy moment could defer it
+  // indefinitely. React Query coalesces repeat invalidations of one key, so a
+  // Spoolman sync reporting a dozen slots at once still costs three refetches.
+  const invalidateSlotQueries = useCallback(() => {
+    queryClient.invalidateQueries({ queryKey: ['spool-assignments'] });
+    queryClient.invalidateQueries({ queryKey: ['spoolman-slot-assignments'] });
+    queryClient.invalidateQueries({ queryKey: ['slotPresets'] });
+  }, [queryClient]);
+
   const handleMessage = useCallback((message: WebSocketMessage) => {
   const handleMessage = useCallback((message: WebSocketMessage) => {
     switch (message.type) {
     switch (message.type) {
       case 'printer_status':
       case 'printer_status':
@@ -386,9 +400,10 @@ export function useWebSocket() {
         break;
         break;
 
 
       case 'spool_assignment_changed':
       case 'spool_assignment_changed':
-        // Spool assigned/unassigned - refresh assignment data across all tabs
-        debouncedInvalidate('spool-assignments');
-        debouncedInvalidate('slotPresets');
+        // Spool assigned/unassigned - refresh assignment data across all tabs.
+        // Both inventory modes: the Spoolman AMS sync raises this event too, and
+        // its slot rows live under their own query keys.
+        invalidateSlotQueries();
         break;
         break;
 
 
       case 'spool_assignment_verified': {
       case 'spool_assignment_verified': {
@@ -417,9 +432,15 @@ export function useWebSocket() {
       }
       }
 
 
       case 'spool_auto_assigned':
       case 'spool_auto_assigned':
-        // RFID tag matched - refresh inventory and assignment data
+        // RFID tag matched - refresh inventory and assignment data.
+        // slotPresets is not optional here: auto-assigning rewrites the slot's
+        // slot_preset_mappings row, and the AMS slot card reads that row ahead
+        // of the live tray_info_idx. Leave it cached and swapping a spool keeps
+        // the *previous* spool's preset name on the card -- the rest of the
+        // card updates off the status push, so it reads as one wrong line
+        // rather than a stale card. Only the manual assign path invalidated it.
         debouncedInvalidate('inventory-spools');
         debouncedInvalidate('inventory-spools');
-        debouncedInvalidate('spool-assignments');
+        invalidateSlotQueries();
         break;
         break;
 
 
       case 'spool_usage_logged':
       case 'spool_usage_logged':
@@ -526,7 +547,7 @@ export function useWebSocket() {
         }
         }
         break;
         break;
     }
     }
-  }, [queryClient, debouncedInvalidate, throttledPrinterStatusUpdate, showToast, t]);
+  }, [queryClient, debouncedInvalidate, invalidateSlotQueries, throttledPrinterStatusUpdate, showToast, t]);
 
 
   // Keep the ref updated with latest handleMessage
   // Keep the ref updated with latest handleMessage
   useEffect(() => {
   useEffect(() => {

+ 22 - 4
frontend/src/pages/PrintersPage.tsx

@@ -176,7 +176,7 @@ import { FileUploadModal } from '../components/FileUploadModal';
 import { PrintModal } from '../components/PrintModal';
 import { PrintModal } from '../components/PrintModal';
 import { PrinterInfoModal } from '../components/PrinterInfoModal';
 import { PrinterInfoModal } from '../components/PrinterInfoModal';
 import { FeedDirectionModal } from '../components/FeedDirectionModal';
 import { FeedDirectionModal } from '../components/FeedDirectionModal';
-import { getAmsLabel, getGlobalTrayId, getFillBarColor, getSpoolmanFillLevel, getFallbackSpoolTag, installedNozzleDiameters, isBambuLabSpool, resolveSlotNozzleDiameter, resolveSlotExtruder, formatSlotLabel, FTS_INLET_SIDE } from '../utils/amsHelpers';
+import { getAmsLabel, getGlobalTrayId, getFillBarColor, getSpoolmanFillLevel, getFallbackSpoolTag, installedNozzleDiameters, isBambuLabSpool, resolveSlotNozzleDiameter, resolveSlotExtruder, formatSlotLabel, slotPresetDescribesTray, FTS_INLET_SIDE } from '../utils/amsHelpers';
 import { MAX_CHAMBER_TEMP_C, getPrinterImage, getWifiStrength, filterCompatibleQueueItems, isPrinterCurrentlyDispatchable } from '../utils/printer';
 import { MAX_CHAMBER_TEMP_C, getPrinterImage, getWifiStrength, filterCompatibleQueueItems, isPrinterCurrentlyDispatchable } from '../utils/printer';
 import { FilamentSlotCircle } from '../components/FilamentSlotCircle';
 import { FilamentSlotCircle } from '../components/FilamentSlotCircle';
 import { Collapsible } from '../components/Collapsible';
 import { Collapsible } from '../components/Collapsible';
@@ -5467,6 +5467,12 @@ function PrinterCard({
                                 const cloudInfo = tray?.tray_info_idx ? filamentInfo?.[tray.tray_info_idx] : null;
                                 const cloudInfo = tray?.tray_info_idx ? filamentInfo?.[tray.tray_info_idx] : null;
                                 // Get saved slot preset mapping (for user-configured slots)
                                 // Get saved slot preset mapping (for user-configured slots)
                                 const slotPreset = slotPresets?.[globalTrayId];
                                 const slotPreset = slotPresets?.[globalTrayId];
+                                // Only trusted while it still describes what the printer reports in the
+                                // slot: the row survives a spool swap, and the display chain below puts
+                                // it ahead of the live filament id (see slotPresetDescribesTray).
+                                const slotPresetName = slotPresetDescribesTray(slotPreset?.preset_id, tray?.tray_info_idx)
+                                  ? slotPreset?.preset_name
+                                  : undefined;
 
 
                                 // Fill level fallback chain: Spoolman → Inventory → AMS remain
                                 // Fill level fallback chain: Spoolman → Inventory → AMS remain
                                 const trayTag = (tray?.tray_uuid || tray?.tag_uid || getFallbackSpoolTag(printer.serial_number, ams.id, slotIdx))?.toUpperCase();
                                 const trayTag = (tray?.tray_uuid || tray?.tag_uid || getFallbackSpoolTag(printer.serial_number, ams.id, slotIdx))?.toUpperCase();
@@ -5512,7 +5518,7 @@ function PrinterCard({
                                   // the hover card shows "Devil Design PLA Basic" rather than the
                                   // the hover card shows "Devil Design PLA Basic" rather than the
                                   // vendor-less form. Strip the "@<printer>..." suffix that
                                   // vendor-less form. Strip the "@<printer>..." suffix that
                                   // BambuStudio appends to user-preset names.
                                   // BambuStudio appends to user-preset names.
-                                  profile: slotPreset?.preset_name || (slotSpoolForFill ? [slotSpoolForFill.brand, slotSpoolForFill.slicer_filament_name?.split('@')[0].trim() || slotSpoolForFill.material].filter(Boolean).join(' ').trim() : null) || inventoryAssignment?.spool?.slicer_filament_name || cloudInfo?.name || tray.tray_sub_brands || tray.tray_type,
+                                  profile: slotPresetName || (slotSpoolForFill ? [slotSpoolForFill.brand, slotSpoolForFill.slicer_filament_name?.split('@')[0].trim() || slotSpoolForFill.material].filter(Boolean).join(' ').trim() : null) || inventoryAssignment?.spool?.slicer_filament_name || cloudInfo?.name || tray.tray_sub_brands || tray.tray_type,
                                   colorName: getColorName(tray.tray_color || '', tray.tray_sub_brands),
                                   colorName: getColorName(tray.tray_color || '', tray.tray_sub_brands),
                                   colorHex: tray.tray_color || null,
                                   colorHex: tray.tray_color || null,
                                   kFactor: formatKValue(tray.k),
                                   kFactor: formatKValue(tray.k),
@@ -5777,6 +5783,12 @@ function PrinterCard({
                       const cloudInfo = tray?.tray_info_idx ? filamentInfo?.[tray.tray_info_idx] : null;
                       const cloudInfo = tray?.tray_info_idx ? filamentInfo?.[tray.tray_info_idx] : null;
                       // Get saved slot preset mapping (for user-configured slots)
                       // Get saved slot preset mapping (for user-configured slots)
                       const slotPreset = slotPresets?.[globalTrayId];
                       const slotPreset = slotPresets?.[globalTrayId];
+                      // Only trusted while it still describes what the printer reports in the
+                      // slot: the row survives a spool swap, and the display chain below puts
+                      // it ahead of the live filament id (see slotPresetDescribesTray).
+                      const slotPresetName = slotPresetDescribesTray(slotPreset?.preset_id, tray?.tray_info_idx)
+                        ? slotPreset?.preset_name
+                        : undefined;
                       const htSlotId = tray?.id ?? 0;
                       const htSlotId = tray?.id ?? 0;
 
 
                         // Fill level fallback chain: Spoolman → Inventory → AMS remain
                         // Fill level fallback chain: Spoolman → Inventory → AMS remain
@@ -5813,7 +5825,7 @@ function PrinterCard({
                         // Build filament data for hover card
                         // Build filament data for hover card
                         const filamentData = tray?.tray_type ? {
                         const filamentData = tray?.tray_type ? {
                           vendor: (isBambuLabSpool(tray) ? 'Bambu Lab' : 'Generic') as 'Bambu Lab' | 'Generic',
                           vendor: (isBambuLabSpool(tray) ? 'Bambu Lab' : 'Generic') as 'Bambu Lab' | 'Generic',
-                          profile: slotPreset?.preset_name || (htSlotSpoolForFill ? [htSlotSpoolForFill.brand, htSlotSpoolForFill.slicer_filament_name?.split('@')[0].trim() || htSlotSpoolForFill.material].filter(Boolean).join(' ').trim() : null) || htInventoryAssignment?.spool?.slicer_filament_name || cloudInfo?.name || tray.tray_sub_brands || tray.tray_type,
+                          profile: slotPresetName || (htSlotSpoolForFill ? [htSlotSpoolForFill.brand, htSlotSpoolForFill.slicer_filament_name?.split('@')[0].trim() || htSlotSpoolForFill.material].filter(Boolean).join(' ').trim() : null) || htInventoryAssignment?.spool?.slicer_filament_name || cloudInfo?.name || tray.tray_sub_brands || tray.tray_type,
                           colorName: getColorName(tray.tray_color || '', tray.tray_sub_brands),
                           colorName: getColorName(tray.tray_color || '', tray.tray_sub_brands),
                           colorHex: tray.tray_color || null,
                           colorHex: tray.tray_color || null,
                           kFactor: formatKValue(tray.k),
                           kFactor: formatKValue(tray.k),
@@ -6211,6 +6223,12 @@ function PrinterCard({
                                 : '';
                                 : '';
                               const extCloudInfo = extTray.tray_info_idx ? filamentInfo?.[extTray.tray_info_idx] : null;
                               const extCloudInfo = extTray.tray_info_idx ? filamentInfo?.[extTray.tray_info_idx] : null;
                               const extSlotPreset = slotPresets?.[255 * 4 + slotTrayId];
                               const extSlotPreset = slotPresets?.[255 * 4 + slotTrayId];
+                              // Only trusted while it still describes what the printer reports in the
+                              // slot: the row survives a spool swap, and the display chain below puts
+                              // it ahead of the live filament id (see slotPresetDescribesTray).
+                              const extSlotPresetName = slotPresetDescribesTray(extSlotPreset?.preset_id, extTray.tray_info_idx)
+                                ? extSlotPreset?.preset_name
+                                : undefined;
 
 
                               const extTrayTag = (extTray.tray_uuid || extTray.tag_uid || getFallbackSpoolTag(printer.serial_number, 255, slotTrayId))?.toUpperCase();
                               const extTrayTag = (extTray.tray_uuid || extTray.tag_uid || getFallbackSpoolTag(printer.serial_number, 255, slotTrayId))?.toUpperCase();
                               const extLinkedSpool = extTrayTag ? linkedSpools?.[extTrayTag] : undefined;
                               const extLinkedSpool = extTrayTag ? linkedSpools?.[extTrayTag] : undefined;
@@ -6245,7 +6263,7 @@ function PrinterCard({
 
 
                               const extFilamentData = {
                               const extFilamentData = {
                                 vendor: (isBambuLabSpool(extTray) ? 'Bambu Lab' : 'Generic') as 'Bambu Lab' | 'Generic',
                                 vendor: (isBambuLabSpool(extTray) ? 'Bambu Lab' : 'Generic') as 'Bambu Lab' | 'Generic',
-                                profile: extSlotPreset?.preset_name || (extSlotSpoolForFill ? [extSlotSpoolForFill.brand, extSlotSpoolForFill.slicer_filament_name?.split('@')[0].trim() || extSlotSpoolForFill.material].filter(Boolean).join(' ').trim() : null) || extInventoryAssignment?.spool?.slicer_filament_name || extCloudInfo?.name || extTray.tray_sub_brands || extTray.tray_type || 'Unknown',
+                                profile: extSlotPresetName || (extSlotSpoolForFill ? [extSlotSpoolForFill.brand, extSlotSpoolForFill.slicer_filament_name?.split('@')[0].trim() || extSlotSpoolForFill.material].filter(Boolean).join(' ').trim() : null) || extInventoryAssignment?.spool?.slicer_filament_name || extCloudInfo?.name || extTray.tray_sub_brands || extTray.tray_type || 'Unknown',
                                 colorName: getColorName(extTray.tray_color || '', extTray.tray_sub_brands),
                                 colorName: getColorName(extTray.tray_color || '', extTray.tray_sub_brands),
                                 colorHex: extTray.tray_color || null,
                                 colorHex: extTray.tray_color || null,
                                 kFactor: formatKValue(extTray.k),
                                 kFactor: formatKValue(extTray.k),

+ 28 - 0
frontend/src/utils/amsHelpers.ts

@@ -632,6 +632,34 @@ export function isBambuLabSpool(tray: {
   return false;
   return false;
 }
 }
 
 
+/**
+ * Does a stored slot preset still describe what is in the slot?
+ *
+ * `slot_preset_mappings` remembers the preset a slot was last configured with,
+ * and the AMS slot card shows that name ahead of anything the printer reports —
+ * which is what lets a slot keep a hand-picked name like "# Bambu PLA Matte
+ * @BBL H2C 0.4 nozzle (Custom)" instead of the plain catalog one. The cost is
+ * that a swapped spool leaves the previous spool's name on the card until the
+ * row is refetched, and until then a cached row outranks live telemetry.
+ *
+ * The printer's own `tray_info_idx` settles it, but only for official Bambu
+ * presets, where the two id forms differ by one letter (setting_id `GFSA01` ↔
+ * filament_id `GFA01`). A user preset genuinely carries two unrelated ids — a
+ * slot configured with `PFUSa3b8b0c664c142` reports `tray_info_idx=P8a85d5a` —
+ * and a local preset (`local_68`) has no printer-side id at all, so neither can
+ * be checked here and both keep the stored name. Same for a slot reporting no
+ * id (generic filament with no tag), which is the case the row exists for.
+ */
+export function slotPresetDescribesTray(
+  presetId: string | null | undefined,
+  trayInfoIdx: string | null | undefined,
+): boolean {
+  const preset = (presetId || '').split('_')[0].toUpperCase();
+  const tray = (trayInfoIdx || '').split('_')[0].toUpperCase();
+  if (!preset.startsWith('GFS') || !tray.startsWith('GF') || tray.startsWith('GFS')) return true;
+  return `GF${preset.slice(3)}` === tray;
+}
+
 export interface AmsTrayLike {
 export interface AmsTrayLike {
   id: number;
   id: number;
   tray_type: string | null | undefined;
   tray_type: string | null | undefined;

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-1thJVCSQ.js


+ 1 - 1
static/index.html

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

Некоторые файлы не были показаны из-за большого количества измененных файлов