소스 검색

fix(archives): kill-switch stops archive as cancelled; offline queue stop stores a reason key; SpoolBuddy config save offers a daemon restart

maziggy 4 일 전
부모
커밋
ac976e0003
95개의 변경된 파일과 324개의 추가작업 그리고 23개의 파일을 삭제
  1. 3 0
      CHANGELOG.md
  2. 2 0
      backend/app/api/routes/print_log.py
  3. 3 1
      backend/app/api/routes/print_queue.py
  4. 2 0
      backend/app/core/database.py
  5. 19 0
      backend/app/main.py
  6. 2 0
      backend/tests/integration/test_failure_reason_vocabulary_migration.py
  7. 120 0
      backend/tests/integration/test_kill_switch_stop_archived_cancelled.py
  8. 2 1
      backend/tests/integration/test_print_queue_api.py
  9. 1 0
      backend/tests/unit/test_forget_printer.py
  10. 4 0
      backend/tests/unit/test_printer_kill_switch.py
  11. 61 0
      frontend/src/__tests__/pages/SpoolBuddySettingsPage.test.tsx
  12. 1 0
      frontend/src/components/EditArchiveModal.tsx
  13. 4 1
      frontend/src/i18n/locales/de.ts
  14. 4 1
      frontend/src/i18n/locales/en.ts
  15. 4 1
      frontend/src/i18n/locales/es.ts
  16. 4 1
      frontend/src/i18n/locales/fr.ts
  17. 4 1
      frontend/src/i18n/locales/it.ts
  18. 4 1
      frontend/src/i18n/locales/ja.ts
  19. 4 1
      frontend/src/i18n/locales/ko.ts
  20. 4 1
      frontend/src/i18n/locales/nl.ts
  21. 4 1
      frontend/src/i18n/locales/pt-BR.ts
  22. 4 1
      frontend/src/i18n/locales/ru.ts
  23. 4 1
      frontend/src/i18n/locales/sv.ts
  24. 4 1
      frontend/src/i18n/locales/tr.ts
  25. 4 1
      frontend/src/i18n/locales/uk.ts
  26. 4 1
      frontend/src/i18n/locales/zh-CN.ts
  27. 4 1
      frontend/src/i18n/locales/zh-TW.ts
  28. 41 1
      frontend/src/pages/spoolbuddy/SpoolBuddySettingsPage.tsx
  29. 0 0
      static/assets/AdditionalSection-DFhWvxlA.js
  30. 0 0
      static/assets/AmsUnitCard-DpdrvLYz.js
  31. 0 0
      static/assets/ArchivesPage-DEc6ZjDZ.js
  32. 0 0
      static/assets/AssignToAmsModal-Cp-L1Ca1.js
  33. 0 0
      static/assets/CamWallPage-BscN3ZAF.js
  34. 0 0
      static/assets/CameraDiagnoseModal-DJoj9Cpc.js
  35. 0 0
      static/assets/CameraPage-ChRY4AJt.js
  36. 0 0
      static/assets/CameraWall-CVzia4Vf.js
  37. 0 0
      static/assets/ColumnConfigModal-BNTOBqKV.js
  38. 0 0
      static/assets/ConfigureAmsSlotModal-FzduqcPo.js
  39. 0 0
      static/assets/ConnectAuthorizePage-BGgBwwAX.js
  40. 0 0
      static/assets/ContextMenu-BdbnKFpc.js
  41. 0 0
      static/assets/ExternalLinkPage-BzcajnTG.js
  42. 0 1
      static/assets/FileManagerPage-BU5NJYuK.js
  43. 0 0
      static/assets/FileUploadModal-tMuutEJ5.js
  44. 0 0
      static/assets/FinancePage-RYqIMDbM.js
  45. 0 0
      static/assets/GCodeViewerPage-Dpuvy_uc.js
  46. 0 0
      static/assets/GroupEditPage-BAb-oIvh.js
  47. 0 0
      static/assets/ImagePreviewModal-ib_nmCUY.js
  48. 0 0
      static/assets/InventoryPage-Cwx6YFc2.js
  49. 0 0
      static/assets/LibraryTrashPage-CFsO1w5L.js
  50. 0 0
      static/assets/LoginPage-Clnn2xVR.js
  51. 0 0
      static/assets/MaintenancePage-D1OVMgKk.js
  52. 0 0
      static/assets/ModelSourcesPage-Dhbhfvql.js
  53. 0 0
      static/assets/NotificationsPage-Bpi-S3Bx.js
  54. 0 1
      static/assets/PdfPreviewModal-BUwJ14k9.js
  55. 0 0
      static/assets/PhotoGalleryModal-FUgcrAOQ.js
  56. 0 0
      static/assets/PrinterLocationsPage-Csy18C9q.js
  57. 0 0
      static/assets/PrintersPage-DN2LKFyH.js
  58. 0 0
      static/assets/ProfilesPage-CUpBDT4E.js
  59. 0 0
      static/assets/ProjectDetailPage-DV3WThwn.js
  60. 0 0
      static/assets/ProjectsPage-GlwUcBhY.js
  61. 0 0
      static/assets/QueuePage-bIUGCbAT.js
  62. 0 0
      static/assets/RichTextEditor-DlzB2xwZ.js
  63. 0 0
      static/assets/SettingsPage-Bro-Vpp3.js
  64. 0 0
      static/assets/SetupPage-DyTGe3yp.js
  65. 0 0
      static/assets/SliceModal-C3QDcYku.js
  66. 0 0
      static/assets/SpoolBuddyAmsPage-iLuX_Pv-.js
  67. 0 0
      static/assets/SpoolBuddyDashboard-5YpvQ5hE.js
  68. 0 0
      static/assets/SpoolBuddyInventoryPage-hpljt7nO.js
  69. 0 1
      static/assets/SpoolBuddySettingsPage-BjufIaGU.js
  70. 1 0
      static/assets/SpoolBuddySettingsPage-R4YL1iUw.js
  71. 0 0
      static/assets/SpoolBuddyWriteTagPage-Bub7AEHB.js
  72. 0 0
      static/assets/SpreadsheetPreviewModal-ey3u9xfT.js
  73. 0 0
      static/assets/StatsPage-C1VGQs4v.js
  74. 0 0
      static/assets/StreamOverlayPage-CySk2YZM.js
  75. 0 0
      static/assets/SystemInfoPage-DcKl-Syh.js
  76. 0 0
      static/assets/de-cAJU3vlP.js
  77. 0 0
      static/assets/es-DMnPhPHh.js
  78. 0 0
      static/assets/fr-4FImw2fq.js
  79. 0 0
      static/assets/index-x8im7lBz.js
  80. 1 1
      static/assets/inventorySearch-CsPruB3X.js
  81. 0 0
      static/assets/it-BvQDa5wf.js
  82. 0 0
      static/assets/ja-DitLMdvB.js
  83. 0 0
      static/assets/ko-B2JMT7Xl.js
  84. 0 0
      static/assets/locationSensorDefaults-C5eDFbKA.js
  85. 0 0
      static/assets/nl-BMI9lZx8.js
  86. 0 0
      static/assets/pdf-Bs2_aTbx.js
  87. 0 0
      static/assets/pt-BR-Bwas9MuF.js
  88. 0 0
      static/assets/ru-Bzgmtaam.js
  89. 0 0
      static/assets/sv-zbZ8xp6l.js
  90. 0 0
      static/assets/tr-yRreDPt3.js
  91. 0 0
      static/assets/uk-JIkDTUTI.js
  92. 0 0
      static/assets/utils-o9MpzUBf.js
  93. 0 0
      static/assets/zh-CN-D2ePgF5W.js
  94. 0 0
      static/assets/zh-TW-B-AbP2Co.js
  95. 1 1
      static/index.html

+ 3 - 0
CHANGELOG.md

@@ -164,6 +164,9 @@ 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
+- **A print stopped by the printer kill switch is archived as cancelled** — The printer reports a stop as failed, and only stops made from Bambuddy were turned into cancelled, so every print the kill switch stopped went into the failure statistics as a failed print with no reason. It is now cancelled, with the reason **Stopped by the printer kill switch**, which the archive editor also offers.
+- **A queue job stopped while its printer was offline is counted as a user cancel** — Its archive stored an English sentence as the failure reason, which showed untranslated and counted as a cause of its own in the failure statistics. It now stores **User cancelled**, like every other stop; existing archives are converted on startup.
+- **Saving the SpoolBuddy backend config says it needs a restart** — The device writes a new backend URL or API token to its config file but keeps using the old ones until its daemon restarts, while the settings page only said "Config queued." It now says a restart applies it and offers **Restart Daemon**, which waits until the device has taken the config so the restart cannot replace it.
 - **A re-sliced archive reads as not printed and shows a cost** — Re-slicing an archive created a new archive marked "completed", so it appeared under **Printed** and was counted as a finished print although nothing had printed. It is now "archived" (shown as **Not printed**), like any file archived without printing, and it shows an estimated filament cost like other archives with grams.
 - **A reprint of a single-plate archive no longer leaves the old timelapse behind** — A timelapse attached by hand, or before Bambuddy recorded plates, was always kept as an unlinked file on a reprint, since on a multi-plate archive it can be another plate's only copy. On an archive with one plate there is no other plate, and it is now deleted with the run it belonged to.
 - **A GitHub backup restore checks the numbers it restores** — Grams, costs, energy, print times and spool-usage weights were copied from the backup as they were, so a damaged or hand-edited backup could put text, negative or absurd values into the totals. Anything that is not a valid number (grams at most 100000, as the archive editor allows) is left empty, and the restore summary says so. A quantity of 0 (a ruined plate) is kept instead of becoming 1.

+ 2 - 0
backend/app/api/routes/print_log.py

@@ -270,6 +270,8 @@ _FAILURE_REASON_KEYS = frozenset(
         # recognise, so leaving it out would delete the classification on the
         # next save of such an archive.
         "noStatusUpdate",
+        # A print the printer kill switch stopped (written at its completion).
+        "killSwitch",
         "other",
     }
 )

+ 3 - 1
backend/app/api/routes/print_queue.py

@@ -2567,7 +2567,9 @@ async def stop_queue_item(
         if archive and archive.status == "printing":
             archive.status = "cancelled"
             archive.completed_at = datetime.now(timezone.utc)
-            archive.failure_reason = "Stopped by user (printer was offline)"
+            # The vocabulary key every user cancel stores (#2974); that the
+            # printer was offline is on the queue row's error_message.
+            archive.failure_reason = "userCancelled"
 
     await db.commit()
     await refresh_batches_after([item.id])

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

@@ -1918,6 +1918,8 @@ async def _migrate_add_print_archive_cost_center(conn) -> None:
 # ``defaultValue`` fallback in both the editor and the Statistics breakdown, and
 # guessing at it would be worse than leaving one honest string in its own bucket.
 _LEGACY_FAILURE_REASON_LABELS: dict[str, str] = {
+    # Written by the offline queue stop before it stored the key (PC-C4).
+    "Stopped by user (printer was offline)": "userCancelled",
     "Adhesion failure": "adhesionFailure",
     "Agotamiento del filamento": "filamentRunout",
     "Alabeo": "warping",

+ 19 - 0
backend/app/main.py

@@ -565,6 +565,10 @@ _bed_cool_waiters: dict[int, dict] = {}
 # as "cancelled" (stopped by user) so the correct notification email is sent.
 _user_stopped_printers: set[int] = set()
 
+# Printers whose running print the kill switch stopped. Their completion is a
+# cancel with the reason "killSwitch", not the "failed" the printer reports.
+_kill_switch_stopped: set[int] = set()
+
 # Offline-notification edge state (#1752): fire `on_printer_offline` exactly
 # once when a printer transitions connected → disconnected. `_printer_last_connected`
 # holds the previous observation so we only fire on the True → False edge (a
@@ -1209,6 +1213,7 @@ async def forget_printer(printer_id: int) -> None:
     for marked in (
         _unauthorized_print_kill_sent,
         _user_stopped_printers,
+        _kill_switch_stopped,
         _printer_offline_published,
         _nozzle_count_updated,
     ):
@@ -2090,6 +2095,7 @@ async def on_printer_status_change(printer_id: int, state: PrinterState):
                 stopped = printer_manager.stop_print(printer_id)
                 if stopped:
                     _unauthorized_print_kill_sent.add(printer_id)
+                    _kill_switch_stopped.add(printer_id)
                     printer_info = printer_manager.get_printer(printer_id)
                     printer_name = printer_info.name if printer_info else f"Printer {printer_id}"
                     filename = state.subtask_name or state.gcode_file or state.current_print or "Unknown"
@@ -4376,6 +4382,7 @@ async def on_print_start(printer_id: int, data: dict):
 
     # Clear any stale user-stopped flag from previous print cycles
     _user_stopped_printers.discard(printer_id)
+    _kill_switch_stopped.discard(printer_id)
     # A new print starts its milestones from zero (#3211). The status path only
     # resets on progress below 5 while not printing, which a printer that goes
     # from FINISH at 100% straight into a new print at a preparation-phase 85%
@@ -7910,7 +7917,15 @@ async def on_print_complete(printer_id: int, data: dict):
             printer_id,
         )
         data = {**data, "status": "cancelled"}
+    elif printer_id in _kill_switch_stopped and _raw_status in ("failed", "aborted"):
+        logger.info(
+            "[CALLBACK] Overriding status '%s' -> 'cancelled' for printer %s (stopped by the printer kill switch)",
+            _raw_status,
+            printer_id,
+        )
+        data = {**data, "status": "cancelled", "_kill_switch": True}
     _user_stopped_printers.discard(printer_id)
+    _kill_switch_stopped.discard(printer_id)
 
     # Raise the plate-clear gate for queued dispatch (#961). Any terminal status
     # may have left material on the bed: a user can cancel ten hours into a
@@ -8420,6 +8435,10 @@ async def on_print_complete(printer_id: int, data: dict):
                 # (issue #2974) and records that the real end time is unknown,
                 # which is also why its logged duration is 0 (#2592).
                 failure_reason = "noStatusUpdate"
+            elif data.get("_kill_switch"):
+                # Not the user's cancel: the kill switch stopped a print that did
+                # not come from Bambuddy.
+                failure_reason = "killSwitch"
             if failure_reason:
                 logger.info("[ARCHIVE] failure_reason=%r (status=%s)", failure_reason, status)
             elif status == "failed" and hms_errors:

+ 2 - 0
backend/tests/integration/test_failure_reason_vocabulary_migration.py

@@ -106,6 +106,8 @@ def test_the_map_covers_every_writer_that_ever_existed() -> None:
     # 3. The two stale-path prose sentences.
     assert m["Stale - print likely cancelled or failed without status update"] == "noStatusUpdate"
     assert m["Stale - reconciled after reconnect, end time unknown"] == "noStatusUpdate"
+    # 4. The offline queue stop (PC-C4).
+    assert m["Stopped by user (printer was offline)"] == "userCancelled"
 
 
 # ---------------------------------------------------------------------------

+ 120 - 0
backend/tests/integration/test_kill_switch_stop_archived_cancelled.py

@@ -0,0 +1,120 @@
+"""A print the kill switch stopped is archived as cancelled, with its reason.
+
+The printer reports a stop as "failed" or "aborted", and only stops made from
+Bambuddy's queue were turned into "cancelled". A kill-switch stop therefore
+went into the failure statistics as a failed print with no reason. It is now
+"cancelled" with the reason ``killSwitch``.
+"""
+
+import asyncio
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from backend.app import main as main_module
+from backend.app.services.archive import ArchiveService
+from backend.tests.integration.test_external_print_confirmation_1898 import (  # noqa: F401
+    _clear_print_state,
+    _drive_print_start,
+)
+
+pytestmark = pytest.mark.integration
+
+
+@pytest.fixture(autouse=True)
+def _clean():
+    for tracked in (main_module._active_prints, main_module._kill_switch_stopped, main_module._user_stopped_printers):
+        tracked.clear()
+    yield
+    for tracked in (main_module._active_prints, main_module._kill_switch_stopped, main_module._user_stopped_printers):
+        tracked.clear()
+
+
+async def _complete(printer_id, status, update):
+    session = AsyncMock()
+    session.__aenter__ = AsyncMock(return_value=session)
+    session.__aexit__ = AsyncMock(return_value=False)
+    session.execute = AsyncMock(return_value=MagicMock())
+    tasks_before = set(asyncio.all_tasks())
+    with (
+        patch("backend.app.main.async_session", return_value=session),
+        patch("backend.app.core.database.async_session", return_value=session),
+        patch("backend.app.main.ws_manager") as ws,
+        patch("backend.app.main.mqtt_relay") as relay,
+        patch("backend.app.main.notification_service") as notif,
+        patch("backend.app.main.smart_plug_manager") as plug,
+        patch("backend.app.main.printer_manager") as pm,
+        patch.object(ArchiveService, "update_archive_status", update),
+    ):
+        ws.send_print_complete = AsyncMock()
+        ws.broadcast = AsyncMock()
+        relay.on_print_complete = AsyncMock()
+        notif.on_print_complete = AsyncMock()
+        plug.on_print_complete = AsyncMock()
+        pm.get_printer.return_value = None
+        await main_module.on_print_complete(
+            printer_id,
+            {"status": status, "filename": "x.gcode", "subtask_name": "Kill_Me", "timelapse_was_active": False},
+        )
+        for task in asyncio.all_tasks() - tasks_before:
+            task.cancel()
+            try:
+                await task
+            except (asyncio.CancelledError, Exception):
+                pass
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("reported", ["failed", "aborted"])
+async def test_a_kill_switch_stop_is_a_cancel_with_its_reason(reported):
+    main_module._active_prints[(7, "Kill_Me.3mf")] = 55
+    main_module._kill_switch_stopped.add(7)
+    update = AsyncMock()
+
+    await _complete(7, reported, update)
+
+    kwargs = update.await_args.kwargs
+    assert (kwargs["status"], kwargs["failure_reason"]) == ("cancelled", "killSwitch")
+    assert 7 not in main_module._kill_switch_stopped
+
+
+@pytest.mark.asyncio
+async def test_a_print_that_failed_on_its_own_stays_failed():
+    main_module._active_prints[(7, "Kill_Me.3mf")] = 55
+    update = AsyncMock()
+
+    await _complete(7, "failed", update)
+
+    assert update.await_args.kwargs["status"] == "failed"
+
+
+@pytest.mark.asyncio
+async def test_the_next_print_starts_without_the_flag(test_engine, printer_factory, monkeypatch):
+    """A stop whose completion never arrived must not mark the next print."""
+    monkeypatch.setattr(main_module.printer_manager, "get_current_print_user", lambda printer_id: None)
+    printer_id = (await printer_factory()).id
+    main_module._kill_switch_stopped.add(printer_id)
+
+    await _drive_print_start(test_engine, SimpleNamespace(id=printer_id), download_ok=False)
+
+    assert printer_id not in main_module._kill_switch_stopped
+
+
+def test_the_reason_is_in_the_vocabulary():
+    from backend.app.api.routes.print_log import _FAILURE_REASON_KEYS
+
+    assert "killSwitch" in _FAILURE_REASON_KEYS
+
+
+def test_the_archive_editor_offers_the_same_vocabulary():
+    """A reason the editor does not list shows untranslated, as its own cause."""
+    import re
+    from pathlib import Path
+
+    from backend.app.api.routes.print_log import _FAILURE_REASON_KEYS
+
+    source = (Path(__file__).parents[3] / "frontend/src/components/EditArchiveModal.tsx").read_text()
+    listed = re.search(r"FAILURE_REASON_KEYS[^=]*=\s*\[(.*?)\]", source, re.S).group(1)
+
+    assert set(re.findall(r"'([^']*)'", listed)) == _FAILURE_REASON_KEYS - {""}

+ 2 - 1
backend/tests/integration/test_print_queue_api.py

@@ -4033,7 +4033,8 @@ async def test_stop_offline_reconciles_linked_archive_status_2603(
     assert item.status == "cancelled"
     assert archive.status == "cancelled", "an offline stop must reconcile the archive, not leave it 'printing'"
     assert archive.completed_at is not None
-    assert archive.failure_reason == "Stopped by user (printer was offline)"
+    # A vocabulary key (#2974), not prose: the stop's detail is on the queue row.
+    assert archive.failure_reason == "userCancelled"
 
 
 @pytest.mark.asyncio

+ 1 - 0
backend/tests/unit/test_forget_printer.py

@@ -78,6 +78,7 @@ def _entries(printer_id: int) -> dict[str, tuple]:
         "_timelapse_baselines": (printer_id, {"video.mp4"}),
         "_bed_cool_waiters": (printer_id, {"threshold": 35.0, "filename": "cube", "registered_at": 1.0}),
         "_user_stopped_printers": (printer_id, None),
+        "_kill_switch_stopped": (printer_id, None),
         "_printer_last_connected": (printer_id, True),
         "_printer_offline_published": (printer_id, None),
         "_printer_offline_notify_tasks": (printer_id, _sleeper()),

+ 4 - 0
backend/tests/unit/test_printer_kill_switch.py

@@ -10,6 +10,7 @@ from backend.app import main as main_module
 def clear_kill_switch_state():
     main_module._kill_switch_setting_cache = None
     main_module._unauthorized_print_kill_sent.clear()
+    main_module._kill_switch_stopped.clear()
     main_module._kill_switch_notification_tasks.clear()
     main_module._expected_prints.clear()
     main_module._active_prints.clear()
@@ -20,6 +21,7 @@ def clear_kill_switch_state():
         if not task.done():
             task.cancel()
     main_module._unauthorized_print_kill_sent.clear()
+    main_module._kill_switch_stopped.clear()
     main_module._kill_switch_notification_tasks.clear()
     main_module._expected_prints.clear()
     main_module._active_prints.clear()
@@ -105,6 +107,8 @@ async def test_unauthorized_active_print_triggers_stop(monkeypatch):
     assert stop_calls == [7]
     unauthorized.assert_awaited_once()
     assert 7 in main_module._unauthorized_print_kill_sent
+    # Its completion is archived as a cancel with the kill switch as reason.
+    assert 7 in main_module._kill_switch_stopped
     broadcast.assert_awaited_once_with(
         {
             "type": "kill_switch_triggered",

+ 61 - 0
frontend/src/__tests__/pages/SpoolBuddySettingsPage.test.tsx

@@ -61,6 +61,8 @@ vi.mock('../../api/client', () => ({
     }),
     triggerUpdate: vi.fn().mockResolvedValue({ status: 'ok', message: '' }),
     getSSHPublicKey: vi.fn().mockResolvedValue({ public_key: 'ssh-ed25519 AAAA test-key' }),
+    updateSystemConfig: vi.fn().mockResolvedValue({ status: 'queued' }),
+    systemCommand: vi.fn().mockResolvedValue({ status: 'queued' }),
   },
 }));
 
@@ -308,4 +310,63 @@ describe('SpoolBuddySettingsPage', () => {
       expect(screen.getByText('Waiting for system stats...')).toBeDefined();
     });
   });
+
+  describe('saving the backend config', () => {
+    // The daemon writes the config to its .env file and keeps running on the
+    // old values: it takes effect when the daemon restarts.
+    async function saveConfig() {
+      renderPage();
+      const url = await screen.findByPlaceholderText('http://192.168.1.100:5000');
+      fireEvent.change(url, { target: { value: 'http://10.0.0.5:8000' } });
+      fireEvent.click(screen.getByText('Save'));
+    }
+
+    it('says a restart applies it and offers one', async () => {
+      const { spoolbuddyApi } = await import('../../api/client');
+      await saveConfig();
+
+      expect(await screen.findByText('Saved. Restart the daemon to apply it.')).toBeDefined();
+      fireEvent.click(screen.getByRole('button', { name: 'Restart Daemon' }));
+      await waitFor(() => {
+        expect(spoolbuddyApi.systemCommand).toHaveBeenCalledWith('sb-test-001', 'restart_daemon');
+      });
+      expect(await screen.findByText('Restarting the daemon...')).toBeDefined();
+    });
+
+    it('is not offered on the device state from before the save', async () => {
+      // The page's device data says nothing is pending until it is refetched.
+      const { spoolbuddyApi } = await import('../../api/client');
+      const [device] = await spoolbuddyApi.getDevices();
+      vi.mocked(spoolbuddyApi.updateSystemConfig).mockImplementationOnce(async () => {
+        vi.mocked(spoolbuddyApi.getDevices).mockImplementation(
+          () => new Promise((resolve) => setTimeout(() => resolve([{ ...device, pending_command: 'apply_system_config' }]), 300)),
+        );
+        return { status: 'queued' };
+      });
+      try {
+        await saveConfig();
+
+        const restart = await screen.findByRole('button', { name: 'Restart Daemon' });
+        expect((restart as HTMLButtonElement).disabled).toBe(true);
+      } finally {
+        vi.mocked(spoolbuddyApi.getDevices).mockResolvedValue([device]);
+      }
+    });
+
+    it('holds the restart until the device has taken the config', async () => {
+      // One command slot: a restart queued now would replace the config.
+      const { spoolbuddyApi } = await import('../../api/client');
+      const [device] = await spoolbuddyApi.getDevices();
+      vi.mocked(spoolbuddyApi.getDevices).mockResolvedValue([{ ...device, pending_command: 'apply_system_config' }]);
+      try {
+        await saveConfig();
+
+        const restart = await screen.findByRole('button', { name: 'Restart Daemon' });
+        await waitFor(() => expect((restart as HTMLButtonElement).disabled).toBe(true));
+        expect(screen.getByText('Waiting for the device to take the config...')).toBeDefined();
+      } finally {
+        vi.mocked(spoolbuddyApi.getDevices).mockResolvedValue([device]);
+      }
+    });
+  });
 });

+ 1 - 0
frontend/src/components/EditArchiveModal.tsx

@@ -28,6 +28,7 @@ export const FAILURE_REASON_KEYS = [
   'powerFailure',
   'userCancelled',
   'noStatusUpdate',
+  'killSwitch',
   'other',
 ] as const;
 

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

@@ -5973,6 +5973,7 @@ export default {
       powerFailure: 'Stromausfall',
       userCancelled: 'Vom Benutzer abgebrochen',
       noStatusUpdate: 'Kein Statusupdate empfangen',
+      killSwitch: 'Vom automatischen Druckstopp beendet',
       other: 'Sonstiges',
     },
     // Archive statuses
@@ -7438,7 +7439,9 @@ export default {
       apiToken: 'API-Token',
       apiTokenPlaceholder: 'API-Token eingeben',
       saveConfig: 'Konfiguration speichern',
-      systemQueued: 'Konfiguration in Warteschlange.',
+      systemSavedRestart: 'Gespeichert. Starte den Daemon neu, um sie zu übernehmen.',
+      systemApplying: 'Warte, bis das Gerät die Konfiguration übernommen hat...',
+      systemRestarting: 'Daemon wird neu gestartet...',
       nfcDiagnostic: 'NFC-Diagnose',
       scaleDiagnostic: 'Waagen-Diagnose',
       readTagDiagnostic: 'Tag-Lese-Diagnose',

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

@@ -6026,6 +6026,7 @@ export default {
       powerFailure: 'Power failure',
       userCancelled: 'User cancelled',
       noStatusUpdate: 'No status update received',
+      killSwitch: 'Stopped by the printer kill switch',
       other: 'Other',
     },
     // Archive statuses
@@ -7491,7 +7492,9 @@ export default {
       apiToken: 'API Token',
       apiTokenPlaceholder: 'Enter API token',
       saveConfig: 'Save Config',
-      systemQueued: 'Config queued.',
+      systemSavedRestart: 'Saved. Restart the daemon to apply it.',
+      systemApplying: 'Waiting for the device to take the config...',
+      systemRestarting: 'Restarting the daemon...',
       nfcDiagnostic: 'NFC Diagnostic',
       scaleDiagnostic: 'Scale Diagnostic',
       readTagDiagnostic: 'Read Tag Diagnostic',

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

@@ -5981,6 +5981,7 @@ export default {
       powerFailure: 'Corte de corriente',
       userCancelled: 'Cancelada por el usuario',
       noStatusUpdate: 'No se recibió actualización de estado',
+      killSwitch: 'Detenida por la parada de impresiones no autorizadas',
       other: 'Otro',
     },
     // Archive statuses
@@ -7446,7 +7447,9 @@ export default {
       apiToken: 'Token de API',
       apiTokenPlaceholder: 'Introduzca el token de API',
       saveConfig: 'Guardar configuración',
-      systemQueued: 'Configuración encolada.',
+      systemSavedRestart: 'Guardado. Reinicia el demonio para aplicarla.',
+      systemApplying: 'Esperando a que el dispositivo reciba la configuración...',
+      systemRestarting: 'Reiniciando el demonio...',
       nfcDiagnostic: 'Diagnóstico de NFC',
       scaleDiagnostic: 'Diagnóstico de la báscula',
       readTagDiagnostic: 'Diagnóstico de lectura de etiqueta',

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

@@ -5961,6 +5961,7 @@ export default {
       powerFailure: 'Coupure courant',
       userCancelled: 'Annulé par l\'utilisateur',
       noStatusUpdate: 'Aucune mise à jour d\'état reçue',
+      killSwitch: "Arrêtée par l'interrupteur d'arrêt automatique",
       other: 'Autre',
     },
     // Archive statuses
@@ -7467,7 +7468,9 @@ export default {
       apiToken: 'Jeton d\'API',
       apiTokenPlaceholder: 'Saisir un jeton d\'API',
       saveConfig: 'Enregistrer la configuration',
-      systemQueued: 'Configuration mise en file.',
+      systemSavedRestart: "Enregistré. Redémarrez le daemon pour l'appliquer.",
+      systemApplying: "En attente de la prise en compte de la configuration par l'appareil...",
+      systemRestarting: 'Redémarrage du daemon...',
       systemFieldsRequired: 'L\'URL du backend est obligatoire.',
       nfcDiagnostic: 'Diagnostic NFC',
       scaleDiagnostic: 'Diagnostic balance',

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

@@ -5960,6 +5960,7 @@ export default {
       powerFailure: 'Mancanza corrente',
       userCancelled: 'Annullato dall\'utente',
       noStatusUpdate: 'Nessun aggiornamento di stato ricevuto',
+      killSwitch: "Fermata dall'arresto automatico della stampa",
       other: 'Altro',
     },
     // Archive statuses
@@ -7466,7 +7467,9 @@ export default {
       apiToken: 'Token API',
       apiTokenPlaceholder: 'Inserisci token API',
       saveConfig: 'Salva configurazione',
-      systemQueued: 'Configurazione in coda.',
+      systemSavedRestart: 'Salvato. Riavvia il daemon per applicarla.',
+      systemApplying: 'In attesa che il dispositivo riceva la configurazione...',
+      systemRestarting: 'Riavvio del daemon...',
       systemFieldsRequired: 'L\'URL del backend è obbligatorio.',
       nfcDiagnostic: 'Diagnostica NFC',
       scaleDiagnostic: 'Diagnostica bilancia',

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

@@ -5974,6 +5974,7 @@ export default {
       powerFailure: '電源障害',
       userCancelled: 'ユーザーによるキャンセル',
       noStatusUpdate: 'ステータス更新を受信できませんでした',
+      killSwitch: '印刷の自動停止で停止',
       other: 'その他',
     },
     // Archive statuses
@@ -7480,7 +7481,9 @@ export default {
       apiToken: 'APIトークン',
       apiTokenPlaceholder: 'APIトークンを入力',
       saveConfig: '設定を保存',
-      systemQueued: '設定をキューに追加しました。',
+      systemSavedRestart: '保存しました。適用するにはデーモンを再起動してください。',
+      systemApplying: 'デバイスが設定を受け取るのを待っています...',
+      systemRestarting: 'デーモンを再起動しています...',
       systemFieldsRequired: 'バックエンドURLは必須です。',
       nfcDiagnostic: 'NFC診断',
       scaleDiagnostic: 'スケール診断',

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

@@ -5721,6 +5721,7 @@ export default {
       powerFailure: '전원 실패',
       userCancelled: '사용자 취소',
       noStatusUpdate: '상태 업데이트를 받지 못함',
+      killSwitch: '무단 인쇄 자동 중지로 중지됨',
       other: '기타'
     },
     statuses: {
@@ -7082,7 +7083,9 @@ export default {
       apiToken: 'API 토큰',
       apiTokenPlaceholder: 'API 토큰 입력',
       saveConfig: '구성 저장',
-      systemQueued: '구성 대기 중.',
+      systemSavedRestart: '저장되었습니다. 적용하려면 데몬을 재시작하세요.',
+      systemApplying: '장치가 구성을 받을 때까지 기다리는 중...',
+      systemRestarting: '데몬을 재시작하는 중...',
       nfcDiagnostic: 'NFC 진단',
       scaleDiagnostic: '저울 진단',
       readTagDiagnostic: '태그 읽기 진단',

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

@@ -6024,6 +6024,7 @@ export default {
       powerFailure: 'Stroomuitval',
       userCancelled: 'Door gebruiker geannuleerd',
       noStatusUpdate: 'Geen statusupdate ontvangen',
+      killSwitch: 'Gestopt door de noodstop voor ongeautoriseerde afdrukken',
       other: 'Overig',
     },
     // Archive statuses
@@ -7489,7 +7490,9 @@ export default {
       apiToken: 'API-token',
       apiTokenPlaceholder: 'Voer API-token in',
       saveConfig: 'Configuratie opslaan',
-      systemQueued: 'Configuratie in wachtrij geplaatst.',
+      systemSavedRestart: 'Opgeslagen. Herstart de daemon om deze toe te passen.',
+      systemApplying: 'Wachten tot het apparaat de configuratie heeft overgenomen...',
+      systemRestarting: 'Daemon wordt herstart...',
       nfcDiagnostic: 'NFC-diagnose',
       scaleDiagnostic: 'Weegschaaldiagnose',
       readTagDiagnostic: 'Tagleesdiagnose',

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

@@ -5960,6 +5960,7 @@ export default {
       powerFailure: 'Falha de energia',
       userCancelled: 'Cancelado pelo usuário',
       noStatusUpdate: 'Nenhuma atualização de status recebida',
+      killSwitch: 'Interrompida pela Parada Automática de Impressão',
       other: 'Outro',
     },
     // Archive statuses
@@ -7466,7 +7467,9 @@ export default {
       apiToken: 'Token de API',
       apiTokenPlaceholder: 'Insira o token de API',
       saveConfig: 'Salvar configuração',
-      systemQueued: 'Configuração enfileirada.',
+      systemSavedRestart: 'Salvo. Reinicie o daemon para aplicá-la.',
+      systemApplying: 'Aguardando o dispositivo receber a configuração...',
+      systemRestarting: 'Reiniciando o daemon...',
       systemFieldsRequired: 'URL do backend é obrigatória.',
       nfcDiagnostic: 'Diagnóstico NFC',
       scaleDiagnostic: 'Diagnóstico da balança',

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

@@ -5709,6 +5709,7 @@ export default {
       powerFailure: "Сбой питания",
       userCancelled: "Отменено пользователем",
       noStatusUpdate: "Обновление статуса не получено",
+      killSwitch: 'Остановлена автоостановкой несанкционированной печати',
       other: "Другое",
     },
     statuses: {
@@ -7070,7 +7071,9 @@ export default {
       apiToken: "Токен API",
       apiTokenPlaceholder: "Введите токен API",
       saveConfig: "Сохранить конфигурацию",
-      systemQueued: "Конфигурация поставлена в очередь.",
+      systemSavedRestart: 'Сохранено. Перезапустите демон, чтобы применить.',
+      systemApplying: 'Ожидание, пока устройство примет конфигурацию...',
+      systemRestarting: 'Перезапуск демона...',
       nfcDiagnostic: "Диагностика NFC",
       scaleDiagnostic: "Диагностика весов",
       readTagDiagnostic: "Диагностика чтения метки",

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

@@ -6022,6 +6022,7 @@ errors: {
       powerFailure: 'Strömavbrott',
       userCancelled: 'Användare avbröt',
       noStatusUpdate: 'Ingen statusuppdatering mottogs',
+      killSwitch: 'Stoppad av nödstoppet för obehöriga utskrifter',
       other: 'Annat',
     },
     // Archive statuses
@@ -7487,7 +7488,9 @@ errors: {
       apiToken: 'API-token',
       apiTokenPlaceholder: 'Ange API-token',
       saveConfig: 'Spara konfiguration',
-      systemQueued: 'Konfiguration köad.',
+      systemSavedRestart: 'Sparad. Starta om demonen för att tillämpa den.',
+      systemApplying: 'Väntar på att enheten tar emot konfigurationen...',
+      systemRestarting: 'Startar om demonen...',
       nfcDiagnostic: 'NFC-diagnostik',
       scaleDiagnostic: 'Vågdiagnostik',
       readTagDiagnostic: 'Läs tagg-diagnostik',

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

@@ -5945,6 +5945,7 @@ export default {
       powerFailure: 'Güç kesintisi',
       userCancelled: 'Kullanıcı iptal etti',
       noStatusUpdate: 'Durum güncellemesi alınmadı',
+      killSwitch: 'Yetkisiz baskıyı otomatik durdurma tarafından durduruldu',
       other: 'Diğer',
     },
     statuses: {
@@ -7379,7 +7380,9 @@ export default {
       apiToken: 'API Belirteci',
       apiTokenPlaceholder: 'API belirtecini girin',
       saveConfig: 'Yapılandırmayı Kaydet',
-      systemQueued: 'Yapılandırma kuyruğa alındı.',
+      systemSavedRestart: "Kaydedildi. Uygulamak için daemon'u yeniden başlatın.",
+      systemApplying: 'Cihazın yapılandırmayı alması bekleniyor...',
+      systemRestarting: 'Daemon yeniden başlatılıyor...',
       nfcDiagnostic: 'NFC Tanılaması',
       scaleDiagnostic: 'Tartı Tanılaması',
       readTagDiagnostic: 'Etiket Okuma Tanılaması',

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

@@ -6014,6 +6014,7 @@ export default {
       powerFailure: "Збій живлення",
       userCancelled: "Користувач скасовано",
       noStatusUpdate: "Оновлення статусу не отримано",
+      killSwitch: 'Зупинено автозупинкою несанкціонованого друку',
       other: "інше",
     },
     // Archive statuses
@@ -7479,7 +7480,9 @@ export default {
       apiToken: "API Токен",
       apiTokenPlaceholder: "Введіть API-токен.",
       saveConfig: "Зберегти конфігурацію",
-      systemQueued: "Конфігурацію в черзі.",
+      systemSavedRestart: 'Збережено. Перезапустіть демон, щоб застосувати.',
+      systemApplying: 'Очікування, поки пристрій прийме конфігурацію...',
+      systemRestarting: 'Перезапуск демона...',
       nfcDiagnostic: "NFC Діагностика",
       scaleDiagnostic: "Діагностика ваг",
       readTagDiagnostic: "Прочитайте діагностику тегів",

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

@@ -5960,6 +5960,7 @@ export default {
       powerFailure: '断电',
       userCancelled: '用户取消',
       noStatusUpdate: '未收到状态更新',
+      killSwitch: '被打印机紧急停止开关停止',
       other: '其他',
     },
     // Archive statuses
@@ -7423,7 +7424,9 @@ export default {
       apiToken: 'API 令牌',
       apiTokenPlaceholder: '输入 API 令牌',
       saveConfig: '保存配置',
-      systemQueued: '配置已加入队列。',
+      systemSavedRestart: '已保存。重启守护进程后生效。',
+      systemApplying: '正在等待设备接收配置...',
+      systemRestarting: '正在重启守护进程...',
       nfcDiagnostic: 'NFC 诊断',
       scaleDiagnostic: '秤诊断',
       readTagDiagnostic: '读取标签诊断',

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

@@ -5960,6 +5960,7 @@ export default {
       powerFailure: '斷電',
       userCancelled: '使用者取消',
       noStatusUpdate: '未收到狀態更新',
+      killSwitch: '被列印機緊急停止開關停止',
       other: '其他',
     },
     // Archive statuses
@@ -7423,7 +7424,9 @@ export default {
       apiToken: 'API 權杖',
       apiTokenPlaceholder: '輸入 API 權杖',
       saveConfig: '儲存設定',
-      systemQueued: '設定已加入佇列。',
+      systemSavedRestart: '已儲存。重新啟動守護程式後生效。',
+      systemApplying: '正在等待裝置接收設定...',
+      systemRestarting: '正在重新啟動守護程式...',
       nfcDiagnostic: 'NFC 診斷',
       scaleDiagnostic: '磅秤診斷',
       readTagDiagnostic: '讀取標籤診斷',

+ 41 - 1
frontend/src/pages/spoolbuddy/SpoolBuddySettingsPage.tsx

@@ -38,11 +38,18 @@ const BLANK_OPTIONS = [
 
 function DeviceTab({ device }: { device: SpoolBuddyDevice }) {
   const { t } = useTranslation();
+  const queryClient = useQueryClient();
   const [diagnosticOpen, setDiagnosticOpen] = useState<'nfc' | 'scale' | 'read_tag' | null>(null);
   const [backendUrl, setBackendUrl] = useState('');
   const [apiToken, setApiToken] = useState('');
   const [systemBusy, setSystemBusy] = useState(false);
   const [systemMsg, setSystemMsg] = useState<{ type: 'ok' | 'error'; text: string } | null>(null);
+  // The daemon writes a saved config to its .env file and keeps running on
+  // the old values until it restarts.
+  const [restartOffered, setRestartOffered] = useState(false);
+  // One command slot per device: a restart queued before the daemon took the
+  // config would replace it.
+  const configPending = device.pending_command === 'apply_system_config';
 
   useEffect(() => {
     if (!backendUrl && device.backend_url) {
@@ -64,7 +71,24 @@ function DeviceTab({ device }: { device: SpoolBuddyDevice }) {
         backendUrl.trim(),
         apiToken.trim() || undefined
       );
-      setSystemMsg({ type: 'ok', text: t('spoolbuddy.settings.systemQueued', 'Config queued.') });
+      // Offered only once the device data shows the queued config, so the
+      // button cannot be pressed on state from before the save.
+      await queryClient.refetchQueries({ queryKey: ['spoolbuddy-devices'] });
+      setSystemMsg({ type: 'ok', text: t('spoolbuddy.settings.systemSavedRestart', 'Saved. Restart the daemon to apply it.') });
+      setRestartOffered(true);
+    } catch (e) {
+      setSystemMsg({ type: 'error', text: e instanceof Error ? e.message : t('common.error', 'Error') });
+    } finally {
+      setSystemBusy(false);
+    }
+  };
+
+  const restartDaemon = async () => {
+    setSystemBusy(true);
+    try {
+      await spoolbuddyApi.systemCommand(device.device_id, 'restart_daemon');
+      setRestartOffered(false);
+      setSystemMsg({ type: 'ok', text: t('spoolbuddy.settings.systemRestarting', 'Restarting the daemon...') });
     } catch (e) {
       setSystemMsg({ type: 'error', text: e instanceof Error ? e.message : t('common.error', 'Error') });
     } finally {
@@ -174,6 +198,22 @@ function DeviceTab({ device }: { device: SpoolBuddyDevice }) {
               {systemMsg.text}
             </div>
           )}
+          {restartOffered && (
+            <div className="flex items-center gap-2">
+              <button
+                onClick={restartDaemon}
+                disabled={systemBusy || configPending}
+                className="px-3 py-1.5 rounded bg-zinc-700 hover:bg-zinc-600 disabled:opacity-50 text-xs font-medium text-zinc-100"
+              >
+                {t('spoolbuddy.quickMenu.restartDaemon', 'Restart Daemon')}
+              </button>
+              {configPending && (
+                <span className="text-xs text-zinc-500">
+                  {t('spoolbuddy.settings.systemApplying', 'Waiting for the device to take the config...')}
+                </span>
+              )}
+            </div>
+          )}
         </div>
 
         {/* Diagnostic Buttons */}

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 0
static/assets/AdditionalSection-DFhWvxlA.js


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 0
static/assets/AmsUnitCard-DpdrvLYz.js


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 0
static/assets/ArchivesPage-DEc6ZjDZ.js


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 0
static/assets/AssignToAmsModal-Cp-L1Ca1.js


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 0
static/assets/CamWallPage-BscN3ZAF.js


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 0
static/assets/CameraDiagnoseModal-DJoj9Cpc.js


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 0
static/assets/CameraPage-ChRY4AJt.js


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 0
static/assets/CameraWall-CVzia4Vf.js


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 0
static/assets/ColumnConfigModal-BNTOBqKV.js


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 0
static/assets/ConfigureAmsSlotModal-FzduqcPo.js


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 0
static/assets/ConnectAuthorizePage-BGgBwwAX.js


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 0
static/assets/ContextMenu-BdbnKFpc.js


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 0
static/assets/ExternalLinkPage-BzcajnTG.js


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 1
static/assets/FileManagerPage-BU5NJYuK.js


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 0
static/assets/FileUploadModal-tMuutEJ5.js


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 0
static/assets/FinancePage-RYqIMDbM.js


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 0
static/assets/GCodeViewerPage-Dpuvy_uc.js


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 0
static/assets/GroupEditPage-BAb-oIvh.js


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 0
static/assets/ImagePreviewModal-ib_nmCUY.js


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 0
static/assets/InventoryPage-Cwx6YFc2.js


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 0
static/assets/LibraryTrashPage-CFsO1w5L.js


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 0
static/assets/LoginPage-Clnn2xVR.js


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 0
static/assets/MaintenancePage-D1OVMgKk.js


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 0
static/assets/ModelSourcesPage-Dhbhfvql.js


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 0
static/assets/NotificationsPage-Bpi-S3Bx.js


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 1
static/assets/PdfPreviewModal-BUwJ14k9.js


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 0
static/assets/PhotoGalleryModal-FUgcrAOQ.js


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 0
static/assets/PrinterLocationsPage-Csy18C9q.js


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 0
static/assets/PrintersPage-DN2LKFyH.js


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 0
static/assets/ProfilesPage-CUpBDT4E.js


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 0
static/assets/ProjectDetailPage-DV3WThwn.js


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 0
static/assets/ProjectsPage-GlwUcBhY.js


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 0
static/assets/QueuePage-bIUGCbAT.js


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 0
static/assets/RichTextEditor-DlzB2xwZ.js


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 0
static/assets/SettingsPage-Bro-Vpp3.js


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 0
static/assets/SetupPage-DyTGe3yp.js


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 0
static/assets/SliceModal-C3QDcYku.js


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 0
static/assets/SpoolBuddyAmsPage-iLuX_Pv-.js


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 0
static/assets/SpoolBuddyDashboard-5YpvQ5hE.js


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 0
static/assets/SpoolBuddyInventoryPage-hpljt7nO.js


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 1
static/assets/SpoolBuddySettingsPage-BjufIaGU.js


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 1 - 0
static/assets/SpoolBuddySettingsPage-R4YL1iUw.js


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 0
static/assets/SpoolBuddyWriteTagPage-Bub7AEHB.js


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 0
static/assets/SpreadsheetPreviewModal-ey3u9xfT.js


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 0
static/assets/StatsPage-C1VGQs4v.js


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 0
static/assets/StreamOverlayPage-CySk2YZM.js


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 0
static/assets/SystemInfoPage-DcKl-Syh.js


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 0
static/assets/de-cAJU3vlP.js


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 0
static/assets/es-DMnPhPHh.js


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 0
static/assets/fr-4FImw2fq.js


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 0
static/assets/index-x8im7lBz.js


+ 1 - 1
static/assets/inventorySearch-CP5BRBza.js → static/assets/inventorySearch-CsPruB3X.js

@@ -1 +1 @@
-import{mt as e}from"./index-BrsvYaVv.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-x8im7lBz.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-BvQDa5wf.js


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 0
static/assets/ja-DitLMdvB.js


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 0
static/assets/ko-B2JMT7Xl.js


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 0
static/assets/locationSensorDefaults-C5eDFbKA.js


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 0
static/assets/nl-BMI9lZx8.js


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 0
static/assets/pdf-Bs2_aTbx.js


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 0
static/assets/pt-BR-Bwas9MuF.js


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 0
static/assets/ru-Bzgmtaam.js


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 0
static/assets/sv-zbZ8xp6l.js


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 0
static/assets/tr-yRreDPt3.js


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 0
static/assets/uk-JIkDTUTI.js


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 0
static/assets/utils-o9MpzUBf.js


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 0
static/assets/zh-CN-D2ePgF5W.js


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 0
static/assets/zh-TW-B-AbP2Co.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-BrsvYaVv.js"></script>
+    <script type="module" crossorigin src="/assets/index-x8im7lBz.js"></script>
     <link rel="modulepreload" crossorigin href="/assets/chunk-aKtaBQYM.js">
     <link rel="modulepreload" crossorigin href="/assets/client-p9vNUtaN.js">
     <link rel="modulepreload" crossorigin href="/assets/QueryClientProvider-B5zKGR0V.js">

이 변경점에서 너무 많은 파일들이 변경되어 몇몇 파일들은 표시되지 않았습니다.