Преглед изворни кода

fix(spoolbuddy): tag link failures, lookup failures and a refused Spoolman URL are reported

maziggy пре 6 дана
родитељ
комит
a202f2502d
94 измењених фајлова са 488 додато и 24 уклоњено
  1. 5 0
      CHANGELOG.md
  2. 32 3
      backend/app/api/routes/spoolbuddy.py
  3. 4 0
      backend/tests/integration/test_spoolbuddy.py
  4. 147 0
      backend/tests/integration/test_spoolbuddy_spoolman_mode_unusable.py
  5. 11 8
      backend/tests/integration/test_spoolman_inventory_api.py
  6. 33 2
      frontend/src/__tests__/components/spoolbuddy/SpoolBuddyLayout.test.tsx
  7. 25 0
      frontend/src/__tests__/hooks/useWebSocket.test.ts
  8. 69 1
      frontend/src/__tests__/pages/SpoolBuddyWriteTagPage.test.tsx
  9. 26 2
      frontend/src/components/spoolbuddy/SpoolBuddyLayout.tsx
  10. 18 0
      frontend/src/hooks/useWebSocket.ts
  11. 6 0
      frontend/src/i18n/locales/de.ts
  12. 6 0
      frontend/src/i18n/locales/en.ts
  13. 6 0
      frontend/src/i18n/locales/es.ts
  14. 6 0
      frontend/src/i18n/locales/fr.ts
  15. 6 0
      frontend/src/i18n/locales/it.ts
  16. 6 0
      frontend/src/i18n/locales/ja.ts
  17. 6 0
      frontend/src/i18n/locales/ko.ts
  18. 6 0
      frontend/src/i18n/locales/nl.ts
  19. 6 0
      frontend/src/i18n/locales/pt-BR.ts
  20. 6 0
      frontend/src/i18n/locales/ru.ts
  21. 6 0
      frontend/src/i18n/locales/sv.ts
  22. 6 0
      frontend/src/i18n/locales/tr.ts
  23. 6 0
      frontend/src/i18n/locales/uk.ts
  24. 6 0
      frontend/src/i18n/locales/zh-CN.ts
  25. 6 0
      frontend/src/i18n/locales/zh-TW.ts
  26. 25 3
      frontend/src/pages/spoolbuddy/SpoolBuddyWriteTagPage.tsx
  27. 0 0
      static/assets/AdditionalSection-C4vV9d3f.js
  28. 0 0
      static/assets/AmsUnitCard-D6_7-ybU.js
  29. 0 0
      static/assets/ArchivesPage-BAhcfUDL.js
  30. 0 0
      static/assets/AssignToAmsModal-DoZ2SBem.js
  31. 0 0
      static/assets/CamWallPage-CYmtFe75.js
  32. 0 0
      static/assets/CameraDiagnoseModal-ZBDLtQyE.js
  33. 0 0
      static/assets/CameraPage-D-46Vto8.js
  34. 0 0
      static/assets/CameraWall-Cv48EudO.js
  35. 0 0
      static/assets/ColumnConfigModal-CGflbKIE.js
  36. 0 0
      static/assets/ConfigureAmsSlotModal-CQDaG4zF.js
  37. 0 0
      static/assets/ConnectAuthorizePage-B3jDFTBy.js
  38. 0 0
      static/assets/ContextMenu-DsTsrcou.js
  39. 0 0
      static/assets/ExternalLinkPage-BKOoBeK6.js
  40. 0 1
      static/assets/FileManagerPage-blZK9UVr.js
  41. 0 0
      static/assets/FileUploadModal-CEM1P4S2.js
  42. 0 0
      static/assets/FinancePage-dbHh0E7U.js
  43. 0 0
      static/assets/GCodeViewerPage-YjWtiRDs.js
  44. 0 0
      static/assets/GroupEditPage-BrDGkWQq.js
  45. 0 0
      static/assets/ImagePreviewModal-BIfOW8De.js
  46. 0 0
      static/assets/InventoryPage-B8Ly0sEg.js
  47. 0 0
      static/assets/LibraryTrashPage-DIcdJZre.js
  48. 0 0
      static/assets/LoginPage-ChTJF9CL.js
  49. 0 0
      static/assets/MaintenancePage-CVMKR9Tm.js
  50. 0 0
      static/assets/ModelSourcesPage-DW78zNGv.js
  51. 0 0
      static/assets/NotificationsPage-Dt3-i7IK.js
  52. 0 1
      static/assets/PdfPreviewModal-DMhWTnZz.js
  53. 0 0
      static/assets/PhotoGalleryModal-BrhX1E-Q.js
  54. 0 0
      static/assets/PrinterLocationsPage-D_UpEBfY.js
  55. 0 0
      static/assets/PrintersPage-CIbwIxlh.js
  56. 0 0
      static/assets/ProfilesPage-BIMaYL-2.js
  57. 0 0
      static/assets/ProjectDetailPage-C565l_L_.js
  58. 0 0
      static/assets/ProjectsPage-CeoPc3uM.js
  59. 0 0
      static/assets/QueuePage-DD82WTJ5.js
  60. 0 0
      static/assets/RichTextEditor-BYL8-ZBR.js
  61. 0 0
      static/assets/SettingsPage-CdLe3KXP.js
  62. 0 0
      static/assets/SetupPage-DxdJmDgn.js
  63. 0 0
      static/assets/SliceModal-dLk5P5CA.js
  64. 0 0
      static/assets/SpoolBuddyAmsPage-D7UT6r5K.js
  65. 0 0
      static/assets/SpoolBuddyCalibrationPage-Bjswh4Uq.js
  66. 0 0
      static/assets/SpoolBuddyDashboard-B3ALuE8i.js
  67. 0 0
      static/assets/SpoolBuddyInventoryPage-Dvcy8YKM.js
  68. 1 1
      static/assets/SpoolBuddySettingsPage-BD9fJQfD.js
  69. 0 0
      static/assets/SpoolBuddyWriteTagPage-C1Lt6Bvc.js
  70. 0 0
      static/assets/SpoolBuddyWriteTagPage-IECptjD2.js
  71. 0 0
      static/assets/SpreadsheetPreviewModal-BFc4yvXl.js
  72. 0 0
      static/assets/StatsPage-BQvWC-nn.js
  73. 0 0
      static/assets/StreamOverlayPage-BN3nb5Yd.js
  74. 0 0
      static/assets/SystemInfoPage-PB9hDbPc.js
  75. 0 0
      static/assets/de-CA69kxvp.js
  76. 0 0
      static/assets/es--sQjgG5r.js
  77. 0 0
      static/assets/fr-xnZWydOl.js
  78. 0 0
      static/assets/index-CRDwbAQC.js
  79. 1 1
      static/assets/inventorySearch-CWXiA9VI.js
  80. 0 0
      static/assets/it-CXZoZunK.js
  81. 0 0
      static/assets/ja-DBLnMcoO.js
  82. 0 0
      static/assets/ko-BLGRioLB.js
  83. 0 0
      static/assets/locationSensorDefaults-DAhSOROZ.js
  84. 0 0
      static/assets/nl-CStbtRev.js
  85. 0 0
      static/assets/pdf-3-PEZLg2.js
  86. 0 0
      static/assets/pt-BR-D7VRKydp.js
  87. 0 0
      static/assets/ru-Dy8PTljD.js
  88. 0 0
      static/assets/sv-B8l4Qqve.js
  89. 0 0
      static/assets/tr-CpWorEfj.js
  90. 0 0
      static/assets/uk-Dk-Pyu68.js
  91. 0 0
      static/assets/utils-0gsZulZw.js
  92. 0 0
      static/assets/zh-CN-Y8Feo-Xd.js
  93. 0 0
      static/assets/zh-TW-C72TU1V0.js
  94. 1 1
      static/index.html

+ 5 - 0
CHANGELOG.md

@@ -164,6 +164,11 @@ All notable changes to Bambuddy will be documented in this file.
 - **The frontend build no longer warns about `path` and `crypto` being externalized for the STEP previewer (#2976)** — `occt-import-js`, the Emscripten build behind STEP previews, requires both modules, but only inside its `ENVIRONMENT_IS_NODE` branches; in the browser it loads its `.wasm` from the URL the preview worker passes and draws randomness from `crypto.getRandomValues`. Vite still externalized both and printed two warnings on every build. `vite.config.ts` now drops exactly those two warnings for that one package through `build.rolldownOptions.onLog`, so an externalization anywhere else, or of any other module, still shows.
 
 ### Fixed
+- **SpoolBuddy** —
+  - The Write Tag page no longer stays on "Writing tag..." when the tag was written but could not be linked to the spool. It shows why: Spoolman not set up, linking in Spoolman failed, or the spool no longer exists.
+  - A scanned tag that cannot be looked up because Spoolman is unavailable, or because the lookup failed, now says so in the kiosk status bar for a few seconds. Before, nothing happened.
+  - With Spoolman switched on and its URL refused by Bambuddy's address check, tag lookups, tag writes and scale weights no longer fall back to the built-in inventory, where they found or changed a different spool. They report Spoolman as unavailable, and Write Tag says so. Switched on without a URL still means the built-in inventory, as everywhere else.
+  - Removing a SpoolBuddy device updates the device list right away.
 - **Archive status in Edit Archive** — the list offers Completed, Failed, Aborted and Cancelled. Choosing "Cancelled" used to save "aborted", which the statistics count as a failure, and a cancelled archive opened showing "Completed". An archive can no longer be set to "Printing" by hand, which had it closed again at the next reconnect as a missed print, with notifications and an extra Print Log entry. The archive card labels an aborted print "aborted" and now also marks a cancelled one, which looked like a normal completed print. Moving an aborted print to Cancelled keeps its recorded reason; marking a cancelled print Completed clears it.
 - **Archive costs** —
   - **Recalculate Costs** and **Rescan** price one print of the archive. An archive printed three times was given the cost of all three. Usage recorded without a price is priced at the default filament cost instead of counting as free. Print History and the statistics still count every print with its own cost.

+ 32 - 3
backend/app/api/routes/spoolbuddy.py

@@ -70,6 +70,19 @@ async def _translate_spoolbuddy_errors():
         raise HTTPException(status_code=503, detail="Spoolman server is not reachable") from exc
 
 
+async def _spoolman_mode(db: AsyncSession) -> bool:
+    """Spoolman mode as every SpoolBuddy screen reads it: switched on, with a URL.
+
+    Distinct from having a usable client: a URL the SSRF guard refuses leaves the
+    mode on but no client, and the spools are still Spoolman's.
+    """
+    from backend.app.api.routes.settings import get_setting
+
+    enabled = await get_setting(db, "spoolman_enabled")
+    url = await get_setting(db, "spoolman_url")
+    return bool(enabled) and enabled.lower() == "true" and bool((url or "").strip())
+
+
 async def _get_spoolman_client_or_none(db: AsyncSession):
     """Return a SpoolmanClient if Spoolman is enabled with a safe URL, else None."""
     from backend.app.api.routes._spoolman_helpers import assert_safe_spoolman_url
@@ -458,10 +471,19 @@ async def nfc_tag_scanned(
         )
         tray_uuid = None
 
-    # _get_spoolman_client_or_none returns a usable client when spoolman_enabled
-    # is true (and the URL passes the SSRF guard), None otherwise — so its
-    # return value doubles as the mode discriminator.
+    # A usable client when Spoolman is on and its URL passes the SSRF guard.
+    # With the mode on but no usable client the spools are still Spoolman's,
+    # and the built-in table must not answer.
     client = await _get_spoolman_client_or_none(db)
+    if client is None and await _spoolman_mode(db):
+        await ws_manager.broadcast(
+            {
+                "type": "spoolman_unavailable",
+                "device_id": req.device_id,
+                "context": "nfc_tag_scanned",
+            }
+        )
+        return {"status": "ok", "matched": False, "spool_id": None}
 
     if client is not None:
         # Spoolman mode — exclusive lookup, no local-DB fallback.
@@ -658,6 +680,8 @@ async def nfc_write_tag(
         raise HTTPException(status_code=404, detail="Device not registered")
 
     sm_client = await _get_spoolman_client_or_none(db)
+    if sm_client is None and await _spoolman_mode(db):
+        raise HTTPException(status_code=503, detail="Spoolman is unavailable")
 
     nfc_warnings: list[str] = []
     if sm_client is None:
@@ -777,6 +801,7 @@ async def nfc_write_result(
                         "spool_id": req.spool_id,
                         "tag_uid": req.tag_uid,
                         "message": "Spoolman not configured",
+                        "reason": "spoolman_not_configured",
                     }
                 )
                 raise HTTPException(
@@ -896,6 +921,7 @@ async def nfc_write_result(
                         # Generic message — full exception (may contain internal URLs/hostnames)
                         # is logged server-side only to prevent information leakage via WebSocket.
                         "message": "Spoolman link failed",
+                        "reason": "spoolman_link_failed",
                     }
                 )
                 raise HTTPException(
@@ -920,6 +946,7 @@ async def nfc_write_result(
                         "device_id": req.device_id,
                         "spool_id": req.spool_id,
                         "message": "Spool not found",
+                        "reason": "spool_not_found",
                     }
                 )
                 return {"status": "ok", "linked": False, "message": "Spool not found"}
@@ -1016,6 +1043,8 @@ async def update_spool_weight(
     from backend.app.models.spool import Spool
 
     sm_client = await _get_spoolman_client_or_none(db)
+    if sm_client is None and await _spoolman_mode(db):
+        raise HTTPException(status_code=503, detail="Spoolman is unavailable")
 
     if sm_client is None:
         # Local mode — exclusive update, no Spoolman fallback.

+ 4 - 0
backend/tests/integration/test_spoolbuddy.py

@@ -2530,6 +2530,7 @@ class TestNfcWriteResultSpoolmanSecurityFixes:
         # Failure broadcast fires so the UI can show the error
         msg = mock_ws.broadcast.call_args[0][0]
         assert msg["type"] == "spoolbuddy_tag_link_failed"
+        assert msg["reason"] == "spoolman_link_failed"
 
     @pytest.mark.asyncio
     @pytest.mark.integration
@@ -2576,6 +2577,7 @@ class TestNfcWriteResultSpoolmanSecurityFixes:
         msg = mock_ws.broadcast.call_args[0][0]
         assert msg["type"] == "spoolbuddy_tag_link_failed"
         assert msg["spool_id"] == 55
+        assert msg["reason"] == "spoolman_link_failed"
 
 
 class TestNfcWriteResultOrphanedSpool:
@@ -2618,6 +2620,7 @@ class TestNfcWriteResultOrphanedSpool:
         broadcast_calls = mock_ws.broadcast.call_args_list
         link_failed = [c[0][0] for c in broadcast_calls if c[0][0].get("type") == "spoolbuddy_tag_link_failed"]
         assert len(link_failed) >= 1
+        assert link_failed[0]["reason"] == "spool_not_found"
 
 
 class TestNfcWriteResultInputValidation:
@@ -2823,6 +2826,7 @@ class TestNfcWriteResultExceptionScrubbing:
         assert msg["type"] == "spoolbuddy_tag_link_failed"
         # Generic message — no internal exception details leaked
         assert msg["message"] == "Spoolman link failed", f"Expected generic message but got: {msg['message']!r}"
+        assert msg["reason"] == "spoolman_link_failed"
         assert "connection refused" not in str(msg), f"Exception text must not appear in WS message: {msg}"
         assert "192.168.1" not in str(msg), f"Internal IP must not appear in WS message: {msg}"
 

+ 147 - 0
backend/tests/integration/test_spoolbuddy_spoolman_mode_unusable.py

@@ -0,0 +1,147 @@
+"""SpoolBuddy with Spoolman switched on but not usable.
+
+With Spoolman enabled and a URL set, the spools are Spoolman's, even while
+that URL is refused. A tag lookup, a tag write and a scale reading then report
+Spoolman as unavailable instead of reading or writing the built-in inventory,
+whose spool ids are not Spoolman's. Switched on without a URL is the built-in
+inventory, as every SpoolBuddy screen shows it.
+"""
+
+from datetime import datetime, timezone
+from unittest.mock import AsyncMock, patch
+
+import pytest
+from httpx import AsyncClient
+from sqlalchemy import select
+
+from backend.app.models.settings import Settings
+from backend.app.models.spool import Spool
+from backend.app.models.spoolbuddy_device import SpoolBuddyDevice
+
+API = "/api/v1/spoolbuddy"
+
+
+@pytest.fixture
+async def unusable_spoolman(db_session):
+    db_session.add(Settings(key="spoolman_enabled", value="true"))
+    # A cloud metadata address, which the URL guard refuses.
+    db_session.add(Settings(key="spoolman_url", value="http://169.254.169.254"))
+    db_session.add(
+        SpoolBuddyDevice(
+            device_id="sb-1",
+            hostname="spoolbuddy-1",
+            ip_address="10.0.0.1",
+            firmware_version="1.0.0",
+            has_nfc=True,
+            has_scale=True,
+            tare_offset=0,
+            calibration_factor=1.0,
+            last_seen=datetime.now(timezone.utc),
+        )
+    )
+    spool = Spool(
+        material="PLA",
+        label_weight=1000,
+        core_weight=250,
+        weight_used=100,
+        tag_uid="AABB1122",
+    )
+    db_session.add(spool)
+    await db_session.commit()
+    return spool.id
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_a_scanned_tag_is_not_looked_up_in_the_built_in_inventory(async_client: AsyncClient, unusable_spoolman):
+    with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
+        mock_ws.broadcast = AsyncMock()
+        resp = await async_client.post(f"{API}/nfc/tag-scanned", json={"device_id": "sb-1", "tag_uid": "AABB1122"})
+
+    assert resp.status_code == 200
+    assert resp.json()["matched"] is False
+    sent = [call.args[0]["type"] for call in mock_ws.broadcast.call_args_list]
+    assert "spoolman_unavailable" in sent
+    assert "spoolbuddy_tag_matched" not in sent
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_a_tag_write_is_refused(async_client: AsyncClient, unusable_spoolman, db_session):
+    with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
+        mock_ws.broadcast = AsyncMock()
+        resp = await async_client.post(
+            f"{API}/nfc/write-tag", json={"device_id": "sb-1", "spool_id": unusable_spoolman}
+        )
+
+    assert resp.status_code == 503
+    device = await db_session.scalar(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == "sb-1"))
+    await db_session.refresh(device)
+    assert device.pending_command is None
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_a_scale_reading_does_not_touch_the_built_in_spool(
+    async_client: AsyncClient, unusable_spoolman, db_session
+):
+    resp = await async_client.post(
+        f"{API}/scale/update-spool-weight", json={"spool_id": unusable_spoolman, "weight_grams": 600}
+    )
+
+    assert resp.status_code == 503
+    spool = await db_session.get(Spool, unusable_spoolman)
+    await db_session.refresh(spool)
+    assert spool.weight_used == 100
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_a_tag_written_after_spoolman_was_switched_off_reports_why(async_client: AsyncClient, db_session):
+    """The write began in Spoolman mode; by the time the tag is written
+    Spoolman is off, so the link cannot be stored."""
+    import json
+
+    db_session.add(
+        SpoolBuddyDevice(
+            device_id="sb-2",
+            hostname="spoolbuddy-2",
+            ip_address="10.0.0.2",
+            firmware_version="1.0.0",
+            has_nfc=True,
+            has_scale=True,
+            tare_offset=0,
+            calibration_factor=1.0,
+            last_seen=datetime.now(timezone.utc),
+            pending_command="write_tag",
+            pending_write_payload=json.dumps({"spool_id": 9, "ndef_data_hex": "deadbeef", "data_origin": "spoolman"}),
+        )
+    )
+    await db_session.commit()
+
+    with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
+        mock_ws.broadcast = AsyncMock()
+        resp = await async_client.post(
+            f"{API}/nfc/write-result",
+            json={"device_id": "sb-2", "spool_id": 9, "tag_uid": "AABBCCDD", "success": True},
+        )
+
+    assert resp.status_code == 502
+    msg = mock_ws.broadcast.call_args[0][0]
+    assert (msg["type"], msg["reason"]) == ("spoolbuddy_tag_link_failed", "spoolman_not_configured")
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_switched_on_without_a_url_is_the_built_in_inventory(async_client: AsyncClient, db_session):
+    db_session.add(Settings(key="spoolman_enabled", value="true"))
+    db_session.add(Settings(key="spoolman_url", value=""))
+    spool = Spool(material="PLA", label_weight=1000, core_weight=250, weight_used=100)
+    db_session.add(spool)
+    await db_session.commit()
+
+    resp = await async_client.post(f"{API}/scale/update-spool-weight", json={"spool_id": spool.id, "weight_grams": 600})
+
+    assert resp.status_code == 200
+    await db_session.refresh(spool)
+    assert spool.weight_used == 650

+ 11 - 8
backend/tests/integration/test_spoolman_inventory_api.py

@@ -1782,8 +1782,9 @@ class TestSpoolmanInventorySSRFSpoolBuddyPath:
         db_session,
         evil_url: str,
     ):
-        """SSRF: _get_spoolman_client_or_none silently disables Spoolman for unsafe URLs
-        on the SpoolBuddy NFC path (tag-scanned broadcasts unknown_tag, not 400)."""
+        """SSRF: _get_spoolman_client_or_none never builds a client for unsafe URLs on
+        the SpoolBuddy NFC path. Spoolman is still the mode, so the scan reports it
+        as unavailable rather than looking the tag up in the built-in inventory."""
         from backend.app.models.settings import Settings
 
         db_session.add(Settings(key="spoolman_enabled", value="true"))
@@ -1799,11 +1800,12 @@ class TestSpoolmanInventorySSRFSpoolBuddyPath:
                 json={"device_id": "sb-ssrf", "tag_uid": "AABBCCDD"},
             )
 
-        # Must not crash or proxy the SSRF URL — unknown_tag is the safe degraded response
+        # Must not crash or proxy the SSRF URL
         assert resp.status_code == 200
-        if mock_ws.broadcast.called:
-            msg = mock_ws.broadcast.call_args[0][0]
-            assert msg["type"] == "spoolbuddy_unknown_tag"
+        assert resp.json()["matched"] is False
+        sent = {call.args[0]["type"] for call in mock_ws.broadcast.call_args_list}
+        assert "spoolman_unavailable" in sent
+        assert sent <= {"spoolman_unavailable", "spoolman_ssrf_blocked"}
 
     @pytest.mark.asyncio
     @pytest.mark.integration
@@ -1893,8 +1895,9 @@ class TestSpoolmanInventorySSRFSpoolBuddyPath:
                 json={"device_id": "sb-ssrf-scale", "spool_id": 1, "weight_grams": 500.0},
             )
 
-        # Must not crash or proxy to an SSRF host
-        assert resp.status_code in (200, 404, 422)
+        # Must not crash or proxy to an SSRF host; Spoolman is unavailable, and the
+        # built-in inventory is not written instead.
+        assert resp.status_code == 503
 
 
 class TestMergeSpoolExtraPreservesKeys:

+ 33 - 2
frontend/src/__tests__/components/spoolbuddy/SpoolBuddyLayout.test.tsx

@@ -4,7 +4,7 @@
  */
 
 import { describe, it, expect, vi } from 'vitest';
-import { render } from '@testing-library/react';
+import { render, screen, act } from '@testing-library/react';
 import React from 'react';
 import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
 import { MemoryRouter, Route, Routes } from 'react-router-dom';
@@ -19,7 +19,7 @@ vi.mock('../../../contexts/AuthContext', async (importOriginal) => {
 
 vi.mock('react-i18next', () => ({
   useTranslation: () => ({
-    t: (_key: string, fallback: string) => fallback,
+    t: (key: string, fallback?: string) => fallback ?? key,
     i18n: { language: 'en', changeLanguage: vi.fn() },
   }),
 }));
@@ -77,6 +77,37 @@ function renderLayout() {
 }
 
 describe('SpoolBuddyLayout', () => {
+  it('says so in the status bar when a scanned tag could not be looked up', async () => {
+    renderLayout();
+
+    act(() => {
+      window.dispatchEvent(
+        new CustomEvent('spoolman-unavailable', { detail: { type: 'spoolman_unavailable', device_id: 'sb-1' } }),
+      );
+    });
+
+    expect(await screen.findByText('spoolbuddy.status.spoolmanUnavailable')).toBeDefined();
+  });
+
+  it('clears the lookup notice after a while and gives the status bar back', async () => {
+    renderLayout();
+    expect(await screen.findByText('SpoolBuddy device disconnected')).toBeDefined();
+    vi.useFakeTimers();
+
+    act(() => {
+      window.dispatchEvent(new CustomEvent('spoolbuddy-lookup-error', { detail: { device_id: 'sb-1' } }));
+    });
+    expect(screen.getByText('spoolbuddy.status.lookupFailed')).toBeDefined();
+    expect(screen.queryByText('SpoolBuddy device disconnected')).toBeNull();
+
+    act(() => {
+      vi.advanceTimersByTime(10000);
+    });
+    expect(screen.queryByText('spoolbuddy.status.lookupFailed')).toBeNull();
+    expect(screen.getByText('SpoolBuddy device disconnected')).toBeDefined();
+    vi.useRealTimers();
+  });
+
   it('renders without crashing', () => {
     const { container } = renderLayout();
     expect(container.firstChild).not.toBeNull();

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

@@ -1072,6 +1072,31 @@ describe('useWebSocket hook', () => {
       vi.useRealTimers();
     });
 
+    it.each([
+      ['spoolbuddy_tag_link_failed', 'spoolbuddy-tag-link-failed'],
+      ['spoolman_unavailable', 'spoolman-unavailable'],
+      ['spoolbuddy_lookup_error', 'spoolbuddy-lookup-error'],
+    ])('passes %s on to the page as %s', async (type, eventName) => {
+      vi.useFakeTimers();
+      const listener = vi.fn();
+      window.addEventListener(eventName, listener);
+
+      const { useWebSocket } = await import('../../hooks/useWebSocket');
+      renderHook(() => useWebSocket(), { wrapper: createWrapper(queryClient) });
+      await vi.advanceTimersByTimeAsync(0);
+      const ws = wsInstances[wsInstances.length - 1]!;
+      act(() => {
+        ws.open();
+        ws.simulateMessage({ type, device_id: 'sb-1', spool_id: 7, reason: 'spool_not_found' });
+      });
+
+      expect(listener).toHaveBeenCalledTimes(1);
+      expect((listener.mock.calls[0][0] as CustomEvent).detail).toMatchObject({ type, device_id: 'sb-1' });
+
+      window.removeEventListener(eventName, listener);
+      vi.useRealTimers();
+    });
+
     it('after a 4401 asks for an auth re-check and reconnects with a fresh token', async () => {
       // The server closes 4401 when the session ended (password change, reset,
       // deactivation), auth was turned on, or a scope refresh failed. A session

+ 69 - 1
frontend/src/__tests__/pages/SpoolBuddyWriteTagPage.test.tsx

@@ -8,7 +8,7 @@
  */
 
 import { describe, it, expect, vi, beforeEach } from 'vitest';
-import { screen, waitFor, fireEvent } from '@testing-library/react';
+import { screen, waitFor, fireEvent, act } from '@testing-library/react';
 import React from 'react';
 import { render } from '@testing-library/react';
 import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
@@ -218,6 +218,74 @@ describe('SpoolBuddyWriteTagPage', () => {
     mockOutletContext.sbState.deviceOnline = false; // reset
   });
 
+  it('leaves "Writing tag..." with the reason when the written tag could not be linked', async () => {
+    const spool = {
+      id: 99,
+      material: 'PLA',
+      label_weight: 1000,
+      weight_used: 0,
+      tag_uid: null,
+      tray_uuid: null,
+      archived_at: null,
+      rgba: 'FF0000FF',
+    };
+    vi.mocked(mockedApi.getSpools).mockResolvedValue([spool as never]);
+    vi.mocked(mockedSpoolbuddyApi.getDevices).mockResolvedValue([
+      { device_id: 'sb-test', hostname: 'sb-test.local' } as never,
+    ]);
+    vi.mocked(mockedSpoolbuddyApi.writeTag).mockResolvedValueOnce({ status: 'queued' } as never);
+
+    mockOutletContext.sbState.deviceOnline = true;
+    renderPage();
+    await waitFor(() => expect(screen.getByText('PLA')).toBeDefined());
+    fireEvent.click(screen.getByText('PLA'));
+    await waitFor(() => expect((screen.getByText('Write Tag') as HTMLButtonElement).disabled).toBe(false));
+    fireEvent.click(screen.getByText('Write Tag'));
+    await waitFor(() => expect(screen.getByText('Writing tag...')).toBeDefined());
+
+    act(() => {
+      window.dispatchEvent(
+        new CustomEvent('spoolbuddy-tag-link-failed', {
+          detail: { type: 'spoolbuddy_tag_link_failed', spool_id: 99, reason: 'spool_not_found', message: 'Spool not found' },
+        }),
+      );
+    });
+
+    await waitFor(() => expect(screen.getByText('spoolbuddy.writeTag.linkFailedSpoolNotFound')).toBeDefined());
+    expect(screen.queryByText('Writing tag...')).toBeNull();
+    mockOutletContext.sbState.deviceOnline = false; // reset
+  });
+
+  it('says Spoolman is unavailable when the server refuses the write for that reason', async () => {
+    const spool = {
+      id: 99,
+      material: 'PLA',
+      label_weight: 1000,
+      weight_used: 0,
+      tag_uid: null,
+      tray_uuid: null,
+      archived_at: null,
+      rgba: 'FF0000FF',
+    };
+    vi.mocked(mockedApi.getSpools).mockResolvedValue([spool as never]);
+    vi.mocked(mockedSpoolbuddyApi.getDevices).mockResolvedValue([
+      { device_id: 'sb-test', hostname: 'sb-test.local' } as never,
+    ]);
+    vi.mocked(mockedSpoolbuddyApi.writeTag).mockRejectedValueOnce(
+      Object.assign(new Error('Spoolman is unavailable'), { status: 503 }),
+    );
+
+    mockOutletContext.sbState.deviceOnline = true;
+    renderPage();
+    await waitFor(() => expect(screen.getByText('PLA')).toBeDefined());
+    fireEvent.click(screen.getByText('PLA'));
+    await waitFor(() => expect((screen.getByText('Write Tag') as HTMLButtonElement).disabled).toBe(false));
+    fireEvent.click(screen.getByText('Write Tag'));
+
+    await waitFor(() => expect(screen.getByText('spoolbuddy.writeTag.spoolmanUnavailable')).toBeDefined());
+    mockOutletContext.sbState.deviceOnline = false; // reset
+  });
+
   it('reads from internal inventory when Spoolman mode is OFF (#1439 parity baseline)', async () => {
     vi.mocked(mockedApi.getSpoolmanSettings).mockResolvedValue({
       spoolman_enabled: 'false',

+ 26 - 2
frontend/src/components/spoolbuddy/SpoolBuddyLayout.tsx

@@ -22,7 +22,7 @@ export function SpoolBuddyLayout() {
   const [selectedPrinterId, setSelectedPrinterId] = useState<number | null>(null);
   const [alert, setAlert] = useState<{ type: 'warning' | 'error' | 'info'; message: string } | null>(null);
   const [displayBrightness, setDisplayBrightness] = useState(100);
-  const { i18n } = useTranslation();
+  const { t, i18n } = useTranslation();
   const navigate = useNavigate();
   const location = useLocation();
   const sbState = useSpoolBuddyState();
@@ -102,6 +102,30 @@ export function SpoolBuddyLayout() {
     }
   }, [effectiveDeviceOnline, updateCheck?.update_available, updateCheck?.latest_version]);
 
+  // A scanned tag that could not be looked up at all, which otherwise looks
+  // like nothing happened. Its own slot above the page and layout alerts, so
+  // it neither replaces them nor is cleared by them, and it clears itself.
+  const [scanNotice, setScanNotice] = useState<{ type: 'error'; message: string } | null>(null);
+  const tRef = useRef(t);
+  tRef.current = t;
+  useEffect(() => {
+    let clearTimer: ReturnType<typeof setTimeout> | undefined;
+    const show = (key: string) => () => {
+      setScanNotice({ type: 'error', message: tRef.current(key) });
+      clearTimeout(clearTimer);
+      clearTimer = setTimeout(() => setScanNotice(null), 10000);
+    };
+    const onUnavailable = show('spoolbuddy.status.spoolmanUnavailable');
+    const onLookupError = show('spoolbuddy.status.lookupFailed');
+    window.addEventListener('spoolman-unavailable', onUnavailable);
+    window.addEventListener('spoolbuddy-lookup-error', onLookupError);
+    return () => {
+      clearTimeout(clearTimer);
+      window.removeEventListener('spoolman-unavailable', onUnavailable);
+      window.removeEventListener('spoolbuddy-lookup-error', onLookupError);
+    };
+  }, []);
+
   // Auto-navigate to dashboard when a NEW tag is detected (transition from no-tag to tag).
   // Blanking itself is handled by swayidle/wlopm at the OS level on the kiosk device —
   // when the HDMI output powers off and the user taps the screen, labwc delivers the
@@ -230,7 +254,7 @@ export function SpoolBuddyLayout() {
           </Suspense>
         </main>
 
-        {!keyboardVisible && <SpoolBuddyStatusBar alert={alert} />}
+        {!keyboardVisible && <SpoolBuddyStatusBar alert={scanNotice ?? alert} />}
         {!keyboardVisible && <SpoolBuddyBottomNav />}
         <VirtualKeyboard onVisibilityChange={setKeyboardVisible} />
       </div>

+ 18 - 0
frontend/src/hooks/useWebSocket.ts

@@ -586,6 +586,24 @@ export function useWebSocket() {
         window.dispatchEvent(new CustomEvent('spoolbuddy-tag-write-failed', { detail: message }));
         break;
 
+      // Written to the chip, but not linked to the spool.
+      case 'spoolbuddy_tag_link_failed':
+        window.dispatchEvent(new CustomEvent('spoolbuddy-tag-link-failed', { detail: message }));
+        break;
+
+      // A scanned tag could not be looked up at all, as opposed to an unknown tag.
+      case 'spoolman_unavailable':
+        window.dispatchEvent(new CustomEvent('spoolman-unavailable', { detail: message }));
+        break;
+
+      case 'spoolbuddy_lookup_error':
+        window.dispatchEvent(new CustomEvent('spoolbuddy-lookup-error', { detail: message }));
+        break;
+
+      case 'spoolbuddy_unregistered':
+        debouncedInvalidate('spoolbuddy-devices');
+        break;
+
       case 'spoolbuddy_online':
         window.dispatchEvent(new CustomEvent('spoolbuddy-online', { detail: message }));
         debouncedInvalidate('spoolbuddy-devices');

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

@@ -7325,6 +7325,8 @@ export default {
       deviceOffline: 'Gerät offline',
       waitingConnection: 'Warte auf Geräteverbindung...',
       systemReady: 'System bereit',
+      spoolmanUnavailable: 'Spoolman ist nicht verfügbar, der Tag konnte nicht nachgeschlagen werden',
+      lookupFailed: 'Das Nachschlagen des Tags ist fehlgeschlagen',
       status: 'Status',
     },
     dashboard: {
@@ -7497,6 +7499,10 @@ export default {
       waiting: 'Warte auf SpoolBuddy...',
       writeSuccess: 'Tag erfolgreich beschrieben!',
       writeFailed: 'Schreiben fehlgeschlagen',
+      linkFailedSpoolmanNotConfigured: 'Der Tag wurde beschrieben, aber Spoolman ist nicht eingerichtet, daher ist er nicht mit der Spule verknüpft',
+      linkFailedSpoolman: 'Der Tag wurde beschrieben, aber die Verknüpfung mit der Spule in Spoolman ist fehlgeschlagen',
+      linkFailedSpoolNotFound: 'Der Tag wurde beschrieben, aber die Spule existiert nicht mehr',
+      spoolmanUnavailable: 'Spoolman ist nicht verfügbar. Prüfe die URL in den Spoolman-Einstellungen.',
       queueFailed: 'Schreibbefehl konnte nicht eingereiht werden',
       tryAgain: 'Erneut versuchen',
       cancel: 'Abbrechen',

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

@@ -7378,6 +7378,8 @@ export default {
       deviceOffline: 'Device Offline',
       waitingConnection: 'Waiting for device connection...',
       systemReady: 'System Ready',
+      spoolmanUnavailable: 'Spoolman is unavailable, so the tag could not be looked up',
+      lookupFailed: 'The tag lookup failed',
       status: 'Status',
     },
     dashboard: {
@@ -7550,6 +7552,10 @@ export default {
       waiting: 'Waiting for SpoolBuddy...',
       writeSuccess: 'Tag written successfully!',
       writeFailed: 'Write failed',
+      linkFailedSpoolmanNotConfigured: 'The tag was written, but Spoolman is not set up, so it is not linked to the spool',
+      linkFailedSpoolman: 'The tag was written, but linking it to the spool in Spoolman failed',
+      linkFailedSpoolNotFound: 'The tag was written, but the spool no longer exists',
+      spoolmanUnavailable: 'Spoolman is unavailable. Check its URL in the Spoolman settings.',
       queueFailed: 'Failed to queue write command',
       tryAgain: 'Try Again',
       cancel: 'Cancel',

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

@@ -7333,6 +7333,8 @@ export default {
       deviceOffline: 'Dispositivo desconectado',
       waitingConnection: 'Esperando la conexión del dispositivo...',
       systemReady: 'Sistema listo',
+      spoolmanUnavailable: 'Spoolman no está disponible, no se pudo buscar la etiqueta',
+      lookupFailed: 'La búsqueda de la etiqueta falló',
       status: 'Estado',
     },
     dashboard: {
@@ -7505,6 +7507,10 @@ export default {
       waiting: 'Esperando a SpoolBuddy...',
       writeSuccess: '¡Etiqueta escrita correctamente!',
       writeFailed: 'Error al escribir',
+      linkFailedSpoolmanNotConfigured: 'La etiqueta se escribió, pero Spoolman no está configurado, así que no está vinculada a la bobina',
+      linkFailedSpoolman: 'La etiqueta se escribió, pero no se pudo vincular a la bobina en Spoolman',
+      linkFailedSpoolNotFound: 'La etiqueta se escribió, pero la bobina ya no existe',
+      spoolmanUnavailable: 'Spoolman no está disponible. Comprueba su URL en los ajustes de Spoolman.',
       queueFailed: 'Error al encolar el comando de escritura',
       tryAgain: 'Inténtelo de nuevo',
       cancel: 'Cancelar',

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

@@ -7313,6 +7313,8 @@ export default {
       deviceOffline: 'Appareil hors ligne',
       waitingConnection: 'En attente de connexion...',
       systemReady: 'Système prêt',
+      spoolmanUnavailable: 'Spoolman n\'est pas disponible, l\'étiquette n\'a pas pu être recherchée',
+      lookupFailed: 'La recherche de l\'étiquette a échoué',
       status: 'Statut',
     },
     dashboard: {
@@ -7485,6 +7487,10 @@ export default {
       waiting: 'En attente de SpoolBuddy...',
       writeSuccess: 'Tag écrit avec succès !',
       writeFailed: 'Échec de l\'écriture',
+      linkFailedSpoolmanNotConfigured: 'L\'étiquette a été écrite, mais Spoolman n\'est pas configuré, elle n\'est donc pas liée à la bobine',
+      linkFailedSpoolman: 'L\'étiquette a été écrite, mais sa liaison à la bobine dans Spoolman a échoué',
+      linkFailedSpoolNotFound: 'L\'étiquette a été écrite, mais la bobine n\'existe plus',
+      spoolmanUnavailable: 'Spoolman n\'est pas disponible. Vérifiez son URL dans les paramètres Spoolman.',
       queueFailed: 'Impossible de mettre en file la commande d\'écriture',
       tryAgain: 'Réessayer',
       cancel: 'Annuler',

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

@@ -7312,6 +7312,8 @@ export default {
       deviceOffline: 'Dispositivo offline',
       waitingConnection: 'In attesa della connessione...',
       systemReady: 'Sistema pronto',
+      spoolmanUnavailable: 'Spoolman non è disponibile, impossibile cercare il tag',
+      lookupFailed: 'La ricerca del tag non è riuscita',
       status: 'Stato',
     },
     dashboard: {
@@ -7484,6 +7486,10 @@ export default {
       waiting: 'In attesa di SpoolBuddy...',
       writeSuccess: 'Tag scritto con successo!',
       writeFailed: 'Scrittura fallita',
+      linkFailedSpoolmanNotConfigured: 'Il tag è stato scritto, ma Spoolman non è configurato, quindi non è collegato alla bobina',
+      linkFailedSpoolman: 'Il tag è stato scritto, ma il collegamento alla bobina in Spoolman non è riuscito',
+      linkFailedSpoolNotFound: 'Il tag è stato scritto, ma la bobina non esiste più',
+      spoolmanUnavailable: 'Spoolman non è disponibile. Controlla l\'URL nelle impostazioni di Spoolman.',
       queueFailed: 'Impossibile accodare il comando di scrittura',
       tryAgain: 'Riprova',
       cancel: 'Annulla',

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

@@ -7326,6 +7326,8 @@ export default {
       deviceOffline: 'デバイスオフライン',
       waitingConnection: 'デバイス接続を待っています...',
       systemReady: 'システム準備完了',
+      spoolmanUnavailable: 'Spoolman が利用できないため、タグを検索できませんでした',
+      lookupFailed: 'タグの検索に失敗しました',
       status: 'ステータス',
     },
     dashboard: {
@@ -7498,6 +7500,10 @@ export default {
       waiting: 'SpoolBuddyを待機中...',
       writeSuccess: 'タグの書込みが完了しました!',
       writeFailed: '書込み失敗',
+      linkFailedSpoolmanNotConfigured: 'タグは書き込まれましたが、Spoolman が設定されていないためスプールにリンクされていません',
+      linkFailedSpoolman: 'タグは書き込まれましたが、Spoolman でスプールへのリンクに失敗しました',
+      linkFailedSpoolNotFound: 'タグは書き込まれましたが、スプールはもう存在しません',
+      spoolmanUnavailable: 'Spoolman が利用できません。Spoolman 設定の URL を確認してください。',
       queueFailed: '書込みコマンドのキューに失敗しました',
       tryAgain: '再試行',
       cancel: 'キャンセル',

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

@@ -6970,6 +6970,8 @@ export default {
       deviceOffline: '장치 오프라인',
       waitingConnection: '장치 연결 대기 중...',
       systemReady: '시스템 준비됨',
+      spoolmanUnavailable: 'Spoolman을 사용할 수 없어 태그를 조회하지 못했습니다',
+      lookupFailed: '태그 조회에 실패했습니다',
       status: '상태'
     },
     dashboard: {
@@ -7137,6 +7139,10 @@ export default {
       waiting: 'SpoolBuddy 대기 중...',
       writeSuccess: '태그 성공적으로 쓰여짐!',
       writeFailed: '쓰기 실패',
+      linkFailedSpoolmanNotConfigured: '태그는 기록되었지만 Spoolman이 설정되지 않아 스풀에 연결되지 않았습니다',
+      linkFailedSpoolman: '태그는 기록되었지만 Spoolman에서 스풀에 연결하지 못했습니다',
+      linkFailedSpoolNotFound: '태그는 기록되었지만 스풀이 더 이상 존재하지 않습니다',
+      spoolmanUnavailable: 'Spoolman을 사용할 수 없습니다. Spoolman 설정에서 URL을 확인하세요.',
       queueFailed: '쓰기 명령 대기 실패',
       tryAgain: '다시 시도',
       cancel: '취소',

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

@@ -7376,6 +7376,8 @@ export default {
       deviceOffline: 'Apparaat offline',
       waitingConnection: 'Wachten op apparaatverbinding...',
       systemReady: 'Systeem gereed',
+      spoolmanUnavailable: 'Spoolman is niet beschikbaar, de tag kon niet worden opgezocht',
+      lookupFailed: 'Het opzoeken van de tag is mislukt',
       status: 'Status',
     },
     dashboard: {
@@ -7548,6 +7550,10 @@ export default {
       waiting: 'Wachten op SpoolBuddy...',
       writeSuccess: 'Tag succesvol geschreven!',
       writeFailed: 'Schrijven mislukt',
+      linkFailedSpoolmanNotConfigured: 'De tag is geschreven, maar Spoolman is niet ingesteld, dus hij is niet aan de spoel gekoppeld',
+      linkFailedSpoolman: 'De tag is geschreven, maar koppelen aan de spoel in Spoolman is mislukt',
+      linkFailedSpoolNotFound: 'De tag is geschreven, maar de spoel bestaat niet meer',
+      spoolmanUnavailable: 'Spoolman is niet beschikbaar. Controleer de URL in de Spoolman-instellingen.',
       queueFailed: 'Schrijfopdracht in wachtrij plaatsen mislukt',
       tryAgain: 'Opnieuw proberen',
       cancel: 'Annuleren',

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

@@ -7312,6 +7312,8 @@ export default {
       deviceOffline: 'Dispositivo offline',
       waitingConnection: 'Aguardando conexão do dispositivo...',
       systemReady: 'Sistema pronto',
+      spoolmanUnavailable: 'O Spoolman não está disponível, não foi possível consultar a etiqueta',
+      lookupFailed: 'A consulta da etiqueta falhou',
       status: 'Status',
     },
     dashboard: {
@@ -7484,6 +7486,10 @@ export default {
       waiting: 'Aguardando SpoolBuddy...',
       writeSuccess: 'Tag gravado com sucesso!',
       writeFailed: 'Falha na gravação',
+      linkFailedSpoolmanNotConfigured: 'A etiqueta foi gravada, mas o Spoolman não está configurado, então ela não está vinculada ao carretel',
+      linkFailedSpoolman: 'A etiqueta foi gravada, mas a vinculação ao carretel no Spoolman falhou',
+      linkFailedSpoolNotFound: 'A etiqueta foi gravada, mas o carretel não existe mais',
+      spoolmanUnavailable: 'O Spoolman não está disponível. Verifique a URL nas configurações do Spoolman.',
       queueFailed: 'Falha ao enfileirar comando de gravação',
       tryAgain: 'Tentar novamente',
       cancel: 'Cancelar',

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

@@ -6958,6 +6958,8 @@ export default {
       deviceOffline: "Устройство не в сети",
       waitingConnection: "Ожидание подключения устройства...",
       systemReady: "Система готова",
+      spoolmanUnavailable: "Spoolman недоступен, метку не удалось найти",
+      lookupFailed: "Не удалось найти метку",
       status: "Состояние",
     },
     dashboard: {
@@ -7125,6 +7127,10 @@ export default {
       waiting: "Ожидание SpoolBuddy...",
       writeSuccess: "Метка успешно записана!",
       writeFailed: "Ошибка записи",
+      linkFailedSpoolmanNotConfigured: "Метка записана, но Spoolman не настроен, поэтому она не связана с катушкой",
+      linkFailedSpoolman: "Метка записана, но связать её с катушкой в Spoolman не удалось",
+      linkFailedSpoolNotFound: "Метка записана, но катушка больше не существует",
+      spoolmanUnavailable: "Spoolman недоступен. Проверьте URL в настройках Spoolman.",
       queueFailed: "Не удалось поставить команду записи в очередь",
       tryAgain: "Повторить",
       cancel: "Отмена",

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

@@ -7374,6 +7374,8 @@ errors: {
       deviceOffline: 'Enhet offline',
       waitingConnection: 'Väntar på enhetsanslutning...',
       systemReady: 'System redo',
+      spoolmanUnavailable: 'Spoolman är inte tillgängligt, taggen kunde inte slås upp',
+      lookupFailed: 'Uppslagningen av taggen misslyckades',
       status: 'Status',
     },
     dashboard: {
@@ -7546,6 +7548,10 @@ errors: {
       waiting: 'Väntar på SpoolBuddy...',
       writeSuccess: 'Tagg skriven!',
       writeFailed: 'Kunde inte skriva tagg',
+      linkFailedSpoolmanNotConfigured: 'Taggen skrevs, men Spoolman är inte konfigurerat, så den är inte kopplad till spolen',
+      linkFailedSpoolman: 'Taggen skrevs, men kopplingen till spolen i Spoolman misslyckades',
+      linkFailedSpoolNotFound: 'Taggen skrevs, men spolen finns inte längre',
+      spoolmanUnavailable: 'Spoolman är inte tillgängligt. Kontrollera URL:en i Spoolman-inställningarna.',
       queueFailed: 'Kunde inte köa skrivkommando',
       tryAgain: 'Försök igen',
       cancel: 'Avbryt',

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

@@ -7267,6 +7267,8 @@ export default {
       deviceOffline: 'Cihaz Çevrimdışı',
       waitingConnection: 'Cihaz bağlantısı bekleniyor...',
       systemReady: 'Sistem Hazır',
+      spoolmanUnavailable: 'Spoolman kullanılamıyor, etiket aranamadı',
+      lookupFailed: 'Etiket araması başarısız oldu',
       status: 'Durum',
     },
     dashboard: {
@@ -7434,6 +7436,10 @@ export default {
       waiting: 'SpoolBuddy bekleniyor...',
       writeSuccess: 'Etiket başarıyla yazıldı!',
       writeFailed: 'Yazma başarısız',
+      linkFailedSpoolmanNotConfigured: 'Etiket yazıldı, ancak Spoolman ayarlanmadığı için makaraya bağlanmadı',
+      linkFailedSpoolman: 'Etiket yazıldı, ancak Spoolman\'da makaraya bağlanamadı',
+      linkFailedSpoolNotFound: 'Etiket yazıldı, ancak makara artık mevcut değil',
+      spoolmanUnavailable: 'Spoolman kullanılamıyor. Spoolman ayarlarındaki URL\'yi kontrol edin.',
       queueFailed: 'Yazma komutu kuyruğa alınamadı',
       tryAgain: 'Tekrar Dene',
       cancel: 'İptal',

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

@@ -7366,6 +7366,8 @@ export default {
       deviceOffline: "Пристрій не в мережі",
       waitingConnection: "Очікування підключення пристрою...",
       systemReady: "Система готова",
+      spoolmanUnavailable: "Spoolman недоступний, мітку не вдалося знайти",
+      lookupFailed: "Не вдалося знайти мітку",
       status: "Статус",
     },
     dashboard: {
@@ -7538,6 +7540,10 @@ export default {
       waiting: "Очікування SpoolBuddy...",
       writeSuccess: "Тег успішно записано!",
       writeFailed: "Помилка запису",
+      linkFailedSpoolmanNotConfigured: "Мітку записано, але Spoolman не налаштовано, тому її не пов'язано з котушкою",
+      linkFailedSpoolman: "Мітку записано, але пов'язати її з котушкою в Spoolman не вдалося",
+      linkFailedSpoolNotFound: "Мітку записано, але котушки більше не існує",
+      spoolmanUnavailable: "Spoolman недоступний. Перевірте URL у налаштуваннях Spoolman.",
       queueFailed: "Не вдалося поставити команду запису в чергу",
       tryAgain: "Спробуйте знову",
       cancel: "Скасувати",

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

@@ -7310,6 +7310,8 @@ export default {
       deviceOffline: '设备离线',
       waitingConnection: '等待设备连接...',
       systemReady: '系统就绪',
+      spoolmanUnavailable: 'Spoolman 不可用,无法查询标签',
+      lookupFailed: '标签查询失败',
       status: '状态',
     },
     dashboard: {
@@ -7482,6 +7484,10 @@ export default {
       waiting: '等待 SpoolBuddy...',
       writeSuccess: '标签写入成功!',
       writeFailed: '写入失败',
+      linkFailedSpoolmanNotConfigured: '标签已写入,但 Spoolman 未配置,因此未关联到料卷',
+      linkFailedSpoolman: '标签已写入,但在 Spoolman 中关联料卷失败',
+      linkFailedSpoolNotFound: '标签已写入,但料卷已不存在',
+      spoolmanUnavailable: 'Spoolman 不可用。请检查 Spoolman 设置中的 URL。',
       queueFailed: '排队写入命令失败',
       tryAgain: '重试',
       cancel: '取消',

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

@@ -7310,6 +7310,8 @@ export default {
       deviceOffline: '裝置離線',
       waitingConnection: '等待裝置連線...',
       systemReady: '系統就緒',
+      spoolmanUnavailable: 'Spoolman 無法使用,無法查詢標籤',
+      lookupFailed: '標籤查詢失敗',
       status: '狀態',
     },
     dashboard: {
@@ -7482,6 +7484,10 @@ export default {
       waiting: '等待 SpoolBuddy...',
       writeSuccess: '標籤寫入成功!',
       writeFailed: '寫入失敗',
+      linkFailedSpoolmanNotConfigured: '標籤已寫入,但 Spoolman 未設定,因此未關聯到料卷',
+      linkFailedSpoolman: '標籤已寫入,但在 Spoolman 中關聯料卷失敗',
+      linkFailedSpoolNotFound: '標籤已寫入,但料卷已不存在',
+      spoolmanUnavailable: 'Spoolman 無法使用。請檢查 Spoolman 設定中的 URL。',
       queueFailed: '佇列寫入命令失敗',
       tryAgain: '重試',
       cancel: '取消',

+ 25 - 3
frontend/src/pages/spoolbuddy/SpoolBuddyWriteTagPage.tsx

@@ -191,7 +191,24 @@ export function SpoolBuddyWriteTagPage() {
     }
   }, [selectedSpool, t]);
 
+  const handleLinkFailed = useCallback((e: Event) => {
+    const detail = (e as CustomEvent).detail;
+    if (detail.spool_id === selectedSpool?.id || detail.data?.spool_id === selectedSpool?.id) {
+      const reason = detail.reason ?? detail.data?.reason;
+      setWriteStatus('error');
+      setWriteMessage(
+        reason === 'spoolman_not_configured'
+          ? t('spoolbuddy.writeTag.linkFailedSpoolmanNotConfigured')
+          : reason === 'spool_not_found'
+            ? t('spoolbuddy.writeTag.linkFailedSpoolNotFound')
+            : t('spoolbuddy.writeTag.linkFailedSpoolman'),
+      );
+      refetchSpools();
+    }
+  }, [selectedSpool, t, refetchSpools]);
+
   useEffect(() => {
+    window.addEventListener('spoolbuddy-tag-link-failed', handleLinkFailed);
     window.addEventListener('spoolbuddy-unknown-tag', handleUnknownTag);
     window.addEventListener('spoolbuddy-tag-matched', handleTagMatched);
     window.addEventListener('spoolbuddy-tag-removed', handleTagRemoved);
@@ -203,8 +220,9 @@ export function SpoolBuddyWriteTagPage() {
       window.removeEventListener('spoolbuddy-tag-removed', handleTagRemoved);
       window.removeEventListener('spoolbuddy-tag-written', handleTagWritten);
       window.removeEventListener('spoolbuddy-tag-write-failed', handleWriteFailed);
+      window.removeEventListener('spoolbuddy-tag-link-failed', handleLinkFailed);
     };
-  }, [handleUnknownTag, handleTagMatched, handleTagRemoved, handleTagWritten, handleWriteFailed]);
+  }, [handleUnknownTag, handleTagMatched, handleTagRemoved, handleTagWritten, handleWriteFailed, handleLinkFailed]);
 
   // Clear selection when switching tabs
   useEffect(() => {
@@ -225,9 +243,13 @@ export function SpoolBuddyWriteTagPage() {
           showToast(w, 'warning');
         }
       }
-    } catch {
+    } catch (err) {
       setWriteStatus('error');
-      setWriteMessage(t('spoolbuddy.writeTag.queueFailed', 'Failed to queue write command'));
+      setWriteMessage(
+        (err as { status?: number } | null)?.status === 503
+          ? t('spoolbuddy.writeTag.spoolmanUnavailable')
+          : t('spoolbuddy.writeTag.queueFailed', 'Failed to queue write command'),
+      );
     }
   };
 

Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/AdditionalSection-C4vV9d3f.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/AmsUnitCard-D6_7-ybU.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/ArchivesPage-BAhcfUDL.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/AssignToAmsModal-DoZ2SBem.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/CamWallPage-CYmtFe75.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/CameraDiagnoseModal-ZBDLtQyE.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/CameraPage-D-46Vto8.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/CameraWall-Cv48EudO.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/ColumnConfigModal-CGflbKIE.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/ConfigureAmsSlotModal-CQDaG4zF.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/ConnectAuthorizePage-B3jDFTBy.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/ContextMenu-DsTsrcou.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/ExternalLinkPage-BKOoBeK6.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 1
static/assets/FileManagerPage-blZK9UVr.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/FileUploadModal-CEM1P4S2.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/FinancePage-dbHh0E7U.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/GCodeViewerPage-YjWtiRDs.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/GroupEditPage-BrDGkWQq.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/ImagePreviewModal-BIfOW8De.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/InventoryPage-B8Ly0sEg.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/LibraryTrashPage-DIcdJZre.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/LoginPage-ChTJF9CL.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/MaintenancePage-CVMKR9Tm.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/ModelSourcesPage-DW78zNGv.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/NotificationsPage-Dt3-i7IK.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 1
static/assets/PdfPreviewModal-DMhWTnZz.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/PhotoGalleryModal-BrhX1E-Q.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/PrinterLocationsPage-D_UpEBfY.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/PrintersPage-CIbwIxlh.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/ProfilesPage-BIMaYL-2.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/ProjectDetailPage-C565l_L_.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/ProjectsPage-CeoPc3uM.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/QueuePage-DD82WTJ5.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/RichTextEditor-BYL8-ZBR.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/SettingsPage-CdLe3KXP.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/SetupPage-DxdJmDgn.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/SliceModal-dLk5P5CA.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/SpoolBuddyAmsPage-D7UT6r5K.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/SpoolBuddyCalibrationPage-Bjswh4Uq.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/SpoolBuddyDashboard-B3ALuE8i.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/SpoolBuddyInventoryPage-Dvcy8YKM.js


Разлика између датотеке није приказан због своје велике величине
+ 1 - 1
static/assets/SpoolBuddySettingsPage-BD9fJQfD.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/SpoolBuddyWriteTagPage-C1Lt6Bvc.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/SpoolBuddyWriteTagPage-IECptjD2.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/SpreadsheetPreviewModal-BFc4yvXl.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/StatsPage-BQvWC-nn.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/StreamOverlayPage-BN3nb5Yd.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/SystemInfoPage-PB9hDbPc.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/de-CA69kxvp.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/es--sQjgG5r.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/fr-xnZWydOl.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/index-CRDwbAQC.js


+ 1 - 1
static/assets/inventorySearch-CB-zSbfy.js → static/assets/inventorySearch-CWXiA9VI.js

@@ -1 +1 @@
-import{mt as e}from"./index-BvLJnwiO.js";function t(t,n){let r=n.trim().toLowerCase();if(!r)return!0;let i=r.match(/^#(\d+)$/);return i?t.id===Number(i[1]):String(t.id).includes(r)||t.material.toLowerCase().includes(r)||(t.brand?.toLowerCase().includes(r)??!1)||(t.color_name?.toLowerCase().includes(r)??!1)||(e(t.color_name,t.rgba,t.color_name_is_synthesized)?.toLowerCase().includes(r)??!1)||(t.subtype?.toLowerCase().includes(r)??!1)||(t.note?.toLowerCase().includes(r)??!1)||(t.slicer_filament_name?.toLowerCase().includes(r)??!1)||(t.storage_location?.toLowerCase().includes(r)??!1)||(t.material_number?.toLowerCase().includes(r)??!1)||(t.suppliers?.some(e=>e.supplier_name.toLowerCase().includes(r)||(e.supplier_article_number?.toLowerCase().includes(r)??!1))??!1)}function n(e,n){return n?e.filter(e=>t(e,n)):e}export{n as t};
+import{mt as e}from"./index-CRDwbAQC.js";function t(t,n){let r=n.trim().toLowerCase();if(!r)return!0;let i=r.match(/^#(\d+)$/);return i?t.id===Number(i[1]):String(t.id).includes(r)||t.material.toLowerCase().includes(r)||(t.brand?.toLowerCase().includes(r)??!1)||(t.color_name?.toLowerCase().includes(r)??!1)||(e(t.color_name,t.rgba,t.color_name_is_synthesized)?.toLowerCase().includes(r)??!1)||(t.subtype?.toLowerCase().includes(r)??!1)||(t.note?.toLowerCase().includes(r)??!1)||(t.slicer_filament_name?.toLowerCase().includes(r)??!1)||(t.storage_location?.toLowerCase().includes(r)??!1)||(t.material_number?.toLowerCase().includes(r)??!1)||(t.suppliers?.some(e=>e.supplier_name.toLowerCase().includes(r)||(e.supplier_article_number?.toLowerCase().includes(r)??!1))??!1)}function n(e,n){return n?e.filter(e=>t(e,n)):e}export{n as t};

Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/it-CXZoZunK.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/ja-DBLnMcoO.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/ko-BLGRioLB.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/locationSensorDefaults-DAhSOROZ.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/nl-CStbtRev.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/pdf-3-PEZLg2.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/pt-BR-D7VRKydp.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/ru-Dy8PTljD.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/sv-B8l4Qqve.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/tr-CpWorEfj.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/uk-Dk-Pyu68.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/utils-0gsZulZw.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/zh-CN-Y8Feo-Xd.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/zh-TW-C72TU1V0.js


+ 1 - 1
static/index.html

@@ -26,7 +26,7 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-BvLJnwiO.js"></script>
+    <script type="module" crossorigin src="/assets/index-CRDwbAQC.js"></script>
     <link rel="modulepreload" crossorigin href="/assets/chunk-aKtaBQYM.js">
     <link rel="modulepreload" crossorigin href="/assets/client-C5b1pVUf.js">
     <link rel="modulepreload" crossorigin href="/assets/QueryClientProvider-BqikUEEO.js">

Неке датотеке нису приказане због велике количине промена