Browse Source

Add Bambuddy Internal URL for Obico snapshots (#3255)

Chris Bennight 1 day ago
parent
commit
d2ea32ef6f
100 changed files with 339 additions and 80 deletions
  1. 1 1
      backend/app/api/routes/obico.py
  2. 33 0
      backend/app/schemas/settings.py
  3. 12 7
      backend/app/services/obico_detection.py
  4. 106 2
      backend/tests/integration/test_obico_api.py
  5. 15 15
      backend/tests/unit/test_obico_detection.py
  6. 3 1
      backend/tests/unit/test_outbound_url_ssrf_guards.py
  7. 49 0
      frontend/src/__tests__/components/FailureDetectionSettings.test.tsx
  8. 1 0
      frontend/src/api/client.ts
  9. 44 2
      frontend/src/components/FailureDetectionSettings.tsx
  10. 5 2
      frontend/src/i18n/locales/de.ts
  11. 5 2
      frontend/src/i18n/locales/en.ts
  12. 5 2
      frontend/src/i18n/locales/es.ts
  13. 5 2
      frontend/src/i18n/locales/fr.ts
  14. 5 2
      frontend/src/i18n/locales/it.ts
  15. 5 2
      frontend/src/i18n/locales/ja.ts
  16. 5 2
      frontend/src/i18n/locales/ko.ts
  17. 5 2
      frontend/src/i18n/locales/nl.ts
  18. 5 2
      frontend/src/i18n/locales/pt-BR.ts
  19. 5 2
      frontend/src/i18n/locales/ru.ts
  20. 5 2
      frontend/src/i18n/locales/sv.ts
  21. 5 2
      frontend/src/i18n/locales/tr.ts
  22. 5 2
      frontend/src/i18n/locales/uk.ts
  23. 5 2
      frontend/src/i18n/locales/zh-CN.ts
  24. 5 2
      frontend/src/i18n/locales/zh-TW.ts
  25. 0 0
      static/assets/AdditionalSection-Bcoa_hnW.js
  26. 0 0
      static/assets/AmsUnitCard-DLM3L08d.js
  27. 0 0
      static/assets/ArchivesPage-MaOGuDlA.js
  28. 0 0
      static/assets/AreaChart-J0I0YlYN.js
  29. 0 0
      static/assets/AssignToAmsModal-kr-0EzwV.js
  30. 0 1
      static/assets/Button-pwVKPiYn.js
  31. 0 0
      static/assets/CamWallPage-C1rIEzhG.js
  32. 0 0
      static/assets/CameraDiagnoseModal-DWbtempu.js
  33. 0 0
      static/assets/CameraPage-C1mWeQfG.js
  34. 0 0
      static/assets/CameraWall-Br9MDE6G.js
  35. 0 1
      static/assets/Card-OUAj0Ayh.js
  36. 0 0
      static/assets/CartesianChart-DcboTNtX.js
  37. 0 0
      static/assets/ColumnConfigModal-B9cmtxVm.js
  38. 0 0
      static/assets/ConfigureAmsSlotModal-C4N9v6PJ.js
  39. 0 0
      static/assets/ConnectAuthorizePage-DtVlpfFV.js
  40. 0 0
      static/assets/ContextMenu-CqE3-J0R.js
  41. 0 0
      static/assets/CopyButton-CyNT06yS.js
  42. 0 0
      static/assets/ErrorBarContext-6wvnfk_q.js
  43. 0 0
      static/assets/ExternalLinkPage-CM4__1rh.js
  44. 0 1
      static/assets/FileManagerPage-Kp8jeXP4.js
  45. 0 0
      static/assets/FileUploadModal-hFWmsB_i.js
  46. 0 0
      static/assets/FinancePage-BfhaugR1.js
  47. 0 0
      static/assets/GCodeViewerPage-C24Va31U.js
  48. 0 0
      static/assets/GcodeToolpathViewer-D4M17KI8.js
  49. 0 0
      static/assets/GroupEditPage-i9TLWDyl.js
  50. 0 0
      static/assets/ImagePreviewModal-BcWeiOrd.js
  51. 0 0
      static/assets/InventoryPage-BWTPGneX.js
  52. 0 0
      static/assets/LibraryTrashPage-Ck7R9blU.js
  53. 0 0
      static/assets/LoginPage-CXIN3BLj.js
  54. 0 0
      static/assets/MaintenancePage-BAMWk5DI.js
  55. 0 0
      static/assets/ModelSourcesPage-C9OvAi9p.js
  56. 0 0
      static/assets/NotificationsPage-5GOJDRkw.js
  57. 0 0
      static/assets/OrbitControls-Bbqf07B-.js
  58. 0 1
      static/assets/OverlayFrame-BUFy7NI1.js
  59. 0 1
      static/assets/PdfPreviewModal-CRMSWXkI.js
  60. 0 0
      static/assets/PhotoGalleryModal-BxTCJXU3.js
  61. 0 0
      static/assets/PrinterLocationsPage-D2AqeJ9i.js
  62. 0 0
      static/assets/PrintersPage-zDUFDj5q.js
  63. 0 0
      static/assets/ProfilesPage-C0jHVdln.js
  64. 0 0
      static/assets/ProjectDetailPage-CaZEFrXK.js
  65. 0 0
      static/assets/ProjectsPage-C2aXY-5x.js
  66. 0 0
      static/assets/QueryClientProvider-Clbyr4Jg.js
  67. 0 0
      static/assets/QueuePage-DTh2aCRG.js
  68. 0 0
      static/assets/ReferenceLine-l3CI8D5f.js
  69. 0 0
      static/assets/RichTextEditor-CtyYZFiu.js
  70. 0 0
      static/assets/SettingsPage-DOroBYzD.js
  71. 0 0
      static/assets/SetupPage-hUHebIlA.js
  72. 0 0
      static/assets/SliceModal-DilRo4MU.js
  73. 0 0
      static/assets/SpoolBuddyAmsPage-CCeWMoIm.js
  74. 0 0
      static/assets/SpoolBuddyCalibrationPage-BPEaykbd.js
  75. 0 0
      static/assets/SpoolBuddyDashboard-B_0MmvgI.js
  76. 0 0
      static/assets/SpoolBuddyInventoryPage-BmxJPbmk.js
  77. 0 1
      static/assets/SpoolBuddySettingsPage-DKia6Rid.js
  78. 0 0
      static/assets/SpoolBuddyWriteTagPage-CMlhWgSL.js
  79. 0 1
      static/assets/SpoolIcon-OZKzbEc8.js
  80. 0 1
      static/assets/SpreadsheetPreviewModal-WUjrw07T.js
  81. 0 0
      static/assets/StatsPage-D0cqrND3.js
  82. 0 0
      static/assets/StreamOverlayPage-CA9KpVK8.css
  83. 0 0
      static/assets/StreamOverlayPage-DWOhiuT-.js
  84. 0 0
      static/assets/SystemInfoPage-ClAReSss.js
  85. 0 0
      static/assets/ToastContext-VFOrCCoA.js
  86. 0 1
      static/assets/Toggle-CwMiiK0g.js
  87. 0 1
      static/assets/arrow-down-C4OBNDqJ.js
  88. 0 1
      static/assets/arrow-left-MSgMxOFV.js
  89. 0 1
      static/assets/arrow-right-Da96kh4S.js
  90. 0 1
      static/assets/arrow-up-Dam8cLfE.js
  91. 0 1
      static/assets/arrow-up-down-C-1Kkt1x.js
  92. 0 1
      static/assets/box-BIRPxdgi.js
  93. 0 1
      static/assets/briefcase-BmuFBTLZ.js
  94. 0 1
      static/assets/calendar-DiVEdzUw.js
  95. 0 1
      static/assets/check-D1Pn8T1J.js
  96. 0 1
      static/assets/chevrons-right-OnQ-9jKz.js
  97. 0 1
      static/assets/chunk-aKtaBQYM.js
  98. 0 0
      static/assets/client-BdOyoZJz.js
  99. 0 1
      static/assets/cloud-off-DmnG-KIw.js
  100. 0 1
      static/assets/cog-pa-nvQzt.js

+ 1 - 1
backend/app/api/routes/obico.py

@@ -36,7 +36,7 @@ async def get_status(
         "sensitivity": settings["sensitivity"],
         "action": settings["action"],
         "poll_interval": settings["poll_interval"],
-        "external_url_configured": bool(settings["external_url"]),
+        "external_url_configured": bool(settings["snapshot_base_url"]),
     }
 
 

+ 33 - 0
backend/app/schemas/settings.py

@@ -1,6 +1,7 @@
 import json
 import re
 from typing import Literal
+from urllib.parse import urlparse
 
 from pydantic import BaseModel, Field, ValidationInfo, field_validator
 
@@ -645,6 +646,9 @@ class AppSettings(BaseModel):
         default="",
         description="Self-hosted Obico ML API base URL (e.g., http://192.168.1.10:3333)",
     )
+    bambuddy_internal_url: str = Field(
+        default="", description="Bambuddy Internal URL for Obico; empty uses External URL"
+    )
     obico_ml_token: str = Field(
         default="",
         description=(
@@ -864,6 +868,7 @@ class AppSettingsUpdate(BaseModel):
     ldap_default_group: str | None = None
     obico_enabled: bool | None = None
     obico_ml_url: str | None = None
+    bambuddy_internal_url: str | None = None
     obico_ml_token: str | None = None
     obico_sensitivity: str | None = None
     obico_action: str | None = None
@@ -988,6 +993,34 @@ class AppSettingsUpdate(BaseModel):
             raise ValueError("obico_enabled_printers must be a JSON array of printer IDs (integers)")
         return v
 
+    @field_validator("bambuddy_internal_url")
+    @classmethod
+    def validate_bambuddy_internal_url(cls, v: str | None) -> str:
+        """Require an absolute http(s) URL, or empty to fall back to External URL.
+
+        Obico's ML server fetches snapshots from this address, so a value
+        without a scheme ("bambuddy:8000") would only fail later, mid-print,
+        as a snapshot error. Rejecting it here surfaces the mistake on save.
+
+        An explicit null clears the field. The settings updater stores None
+        as the literal "None", which would read back as an address.
+        """
+        candidate = (v or "").strip()
+        if not candidate:
+            return ""
+        error = "bambuddy_internal_url must be a full http:// or https:// address"
+        if any(ch.isspace() for ch in candidate):
+            raise ValueError(error)
+        try:
+            parsed = urlparse(candidate)
+            hostname = parsed.hostname
+            _ = parsed.port  # raises on a port outside 0-65535
+        except ValueError:
+            raise ValueError(error) from None
+        if parsed.scheme not in ("http", "https") or not hostname:
+            raise ValueError(error)
+        return candidate
+
     @staticmethod
     def _validate_preset_triple(v: str | None, field_name: str, lo: int, hi: int) -> str | None:
         """Validate a JSON array of exactly 3 ints in [lo, hi]. Empty = defaults."""

+ 12 - 7
backend/app/services/obico_detection.py

@@ -130,6 +130,7 @@ class ObicoDetectionService:
         keys = [
             "obico_enabled",
             "obico_ml_url",
+            "bambuddy_internal_url",
             "obico_ml_token",
             "obico_sensitivity",
             "obico_action",
@@ -158,7 +159,11 @@ class ObicoDetectionService:
             "action": rows.get("obico_action", "notify"),
             "poll_interval": int(rows.get("obico_poll_interval", "10")),
             "enabled_printers": enabled_printers,
-            "external_url": (rows.get("external_url") or "").rstrip("/"),
+            # Where Obico's ML server fetches snapshots: the Internal URL when
+            # set, otherwise the public External URL.
+            "snapshot_base_url": (
+                (rows.get("bambuddy_internal_url") or "").strip() or (rows.get("external_url") or "").strip()
+            ).rstrip("/"),
         }
 
     # ---- main loop ----
@@ -297,17 +302,17 @@ class ObicoDetectionService:
             self._no_verdict(printer_id, f"Failed to capture snapshot for printer {printer_id}")
             return
 
-        external_url = settings.get("external_url") or ""
-        if not external_url:
+        snapshot_base_url = settings.get("snapshot_base_url") or ""
+        if not snapshot_base_url:
             self._no_verdict(
                 printer_id,
-                "external_url setting is empty — Obico's ML API needs a reachable URL to fetch the snapshot from. "
-                "Set Settings → General → External URL.",
+                "bambuddy_internal_url and external_url settings are empty — Obico's ML API needs a reachable URL to fetch the snapshot from. "
+                "Set Settings → Failure Detection → Bambuddy Internal URL or Settings → Network → External URL.",
             )
             return
 
         nonce = await stash_frame(frame)
-        snapshot_url = f"{external_url}/api/v1/obico/cached-frame/{nonce}"
+        snapshot_url = f"{snapshot_base_url}/api/v1/obico/cached-frame/{nonce}"
         ml_url = f"{settings['ml_url']}/p/"
 
         try:
@@ -403,7 +408,7 @@ class ObicoDetectionService:
 
         ``error``    the most recent poll produced no verdict. ``error`` carries
                      the reason — a rejected token, an unreachable ML API, a
-                     camera that would not yield a frame, an unset External URL.
+                     camera that would not yield a frame, a missing Bambuddy address.
         ``unknown``  monitored, but no inference has come back yet. The state
                      entry is created when the print is first seen, which is
                      before the first capture, so this is the honest answer for

+ 106 - 2
backend/tests/integration/test_obico_api.py

@@ -5,11 +5,18 @@ pre-captured JPEG frames. This endpoint lets the detection loop sidestep Obico's
 hardcoded 5s read timeout by pre-populating a cache before issuing the ML call.
 """
 
+from unittest.mock import AsyncMock, MagicMock, patch
+
 import pytest
-from httpx import AsyncClient
+from httpx import AsyncClient, Request, Response
 
 from backend.app.core.printer_scope import ALL_PRINTERS
-from backend.app.services.obico_detection import _frame_cache, obico_detection_service, stash_frame
+from backend.app.services.obico_detection import (
+    ObicoDetectionService,
+    _frame_cache,
+    obico_detection_service,
+    stash_frame,
+)
 from backend.app.services.obico_smoothing import PrintState
 
 FAKE_JPEG = b"\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x01\x00\x00\x01\x00\x01\x00\x00\xff\xd9"
@@ -73,6 +80,103 @@ class TestObicoCachedFrame:
         assert "no-store" in response.headers.get("cache-control", "")
 
 
+class TestBambuddyInternalUrl:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    @pytest.mark.parametrize(
+        ("bambuddy_internal_url", "external_url", "expected_base"),
+        [
+            (None, "https://bambuddy.example.com", "https://bambuddy.example.com"),
+            ("", "https://bambuddy.example.com/", "https://bambuddy.example.com"),
+            ("http://bambuddy:8000/", "https://bambuddy.example.com", "http://bambuddy:8000"),
+            ("http://192.168.1.20:8000", "", "http://192.168.1.20:8000"),
+        ],
+    )
+    async def test_saved_url_controls_snapshot_callback(
+        self, async_client: AsyncClient, bambuddy_internal_url, external_url, expected_base
+    ):
+        updates = {"external_url": external_url, "obico_ml_url": "http://obico:3333"}
+        if bambuddy_internal_url is not None:
+            updates["bambuddy_internal_url"] = bambuddy_internal_url
+        response = await async_client.put("/api/v1/settings/", json=updates)
+        assert response.status_code == 200
+        saved = (await async_client.get("/api/v1/settings/")).json()
+        assert saved["external_url"] == external_url
+        assert saved["bambuddy_internal_url"] == (bambuddy_internal_url or "")
+        status = (await async_client.get("/api/v1/obico/status")).json()
+        assert status["external_url_configured"] is True
+
+        async def fetch_snapshot(url, *, params, headers):
+            assert url == "http://obico:3333/p/"
+            assert params["img"].startswith(f"{expected_base}/api/v1/obico/cached-frame/")
+            frame = await async_client.get(params["img"])
+            assert frame.status_code == 200
+            assert frame.content == FAKE_JPEG
+            return Response(200, json={"detections": []}, request=Request("GET", url))
+
+        svc = ObicoDetectionService()
+        settings = await svc._load_settings()
+        mock_client = MagicMock()
+        mock_client.get = AsyncMock(side_effect=fetch_snapshot)
+        mock_client.__aenter__ = AsyncMock(return_value=mock_client)
+        mock_client.__aexit__ = AsyncMock(return_value=False)
+        with (
+            patch("backend.app.services.obico_detection.httpx.AsyncClient", return_value=mock_client),
+            patch.object(svc, "_capture_frame", new=AsyncMock(return_value=FAKE_JPEG)),
+        ):
+            await svc._check_printer(1, MagicMock(state="RUNNING", task_name="job", subtask_name=""), settings)
+        mock_client.get.assert_awaited_once()
+        assert svc.get_per_printer()[1]["class"] == "safe"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_empty_value_clears_the_bambuddy_internal_url(self, async_client: AsyncClient):
+        response = await async_client.put(
+            "/api/v1/settings/",
+            json={"bambuddy_internal_url": "http://bambuddy:8000", "external_url": "https://bambuddy.example.com"},
+        )
+        assert response.status_code == 200
+        response = await async_client.put("/api/v1/settings/", json={"bambuddy_internal_url": ""})
+        assert response.status_code == 200
+        assert response.json()["bambuddy_internal_url"] == ""
+        assert response.json()["external_url"] == "https://bambuddy.example.com"
+        assert (await ObicoDetectionService()._load_settings())["snapshot_base_url"] == "https://bambuddy.example.com"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    @pytest.mark.parametrize(
+        "value",
+        ["bambuddy:8000", "192.168.1.20:8000", "ftp://bambuddy", "http://", "http://bam buddy:8000", "http://h:99999"],
+    )
+    async def test_address_without_http_scheme_is_rejected(self, async_client: AsyncClient, value):
+        response = await async_client.put("/api/v1/settings/", json={"bambuddy_internal_url": value})
+        assert response.status_code == 422
+        saved = (await async_client.get("/api/v1/settings/")).json()
+        assert saved["bambuddy_internal_url"] == ""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_null_clears_instead_of_storing_none(self, async_client: AsyncClient):
+        await async_client.put(
+            "/api/v1/settings/",
+            json={"bambuddy_internal_url": "http://bambuddy:8000", "external_url": "https://bambuddy.example.com"},
+        )
+        response = await async_client.put("/api/v1/settings/", json={"bambuddy_internal_url": None})
+        assert response.status_code == 200
+        assert response.json()["bambuddy_internal_url"] == ""
+        assert (await ObicoDetectionService()._load_settings())["snapshot_base_url"] == "https://bambuddy.example.com"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_surrounding_whitespace_is_stripped(self, async_client: AsyncClient):
+        response = await async_client.put(
+            "/api/v1/settings/", json={"bambuddy_internal_url": "  http://bambuddy:8000/ "}
+        )
+        assert response.status_code == 200
+        assert response.json()["bambuddy_internal_url"] == "http://bambuddy:8000/"
+        assert (await ObicoDetectionService()._load_settings())["snapshot_base_url"] == "http://bambuddy:8000"
+
+
 class TestObicoPrinterStatus:
     """The lightweight /obico/printer-status endpoint for printer-card badges (#1546)."""
 

+ 15 - 15
backend/tests/unit/test_obico_detection.py

@@ -161,7 +161,7 @@ class TestMlApiToken:
             "action": "notify",
             "poll_interval": 10,
             "enabled_printers": None,
-            "external_url": "http://bambuddy:8000",
+            "snapshot_base_url": "http://bambuddy:8000",
         }
         base.update(overrides)
         return base
@@ -354,7 +354,7 @@ class TestPollOneStateLifecycle:
             "action": "notify",
             "poll_interval": 10,
             "enabled_printers": None,
-            "external_url": "http://bambuddy:8000",
+            "snapshot_base_url": "http://bambuddy:8000",
         }
         status = MagicMock(state="RUNNING", task_name="new_task", subtask_name="")
 
@@ -387,7 +387,7 @@ class TestPollOneStateLifecycle:
             "action": "notify",
             "poll_interval": 10,
             "enabled_printers": None,
-            "external_url": "http://bambuddy:8000",
+            "snapshot_base_url": "http://bambuddy:8000",
         }
         status = MagicMock(state="RUNNING", task_name="job", subtask_name="")
 
@@ -416,7 +416,7 @@ class TestPollOneStateLifecycle:
             "action": "notify",
             "poll_interval": 10,
             "enabled_printers": None,
-            "external_url": "http://bambuddy:8000",
+            "snapshot_base_url": "http://bambuddy:8000",
         }
         status = MagicMock(state="RUNNING", task_name="job", subtask_name="")
 
@@ -451,7 +451,7 @@ class TestPollOneStateLifecycle:
             "action": "notify",
             "poll_interval": 10,
             "enabled_printers": None,
-            "external_url": "http://bambuddy:8000",
+            "snapshot_base_url": "http://bambuddy:8000",
         }
         status = MagicMock(state="RUNNING", task_name="job", subtask_name="")
 
@@ -669,7 +669,7 @@ class TestCheckPrinterUsesCachedFrameUrl:
             "action": "notify",
             "poll_interval": 10,
             "enabled_printers": None,
-            "external_url": "http://bambuddy:8000",
+            "snapshot_base_url": "http://bambuddy:8000",
         }
         status = MagicMock(state="RUNNING", task_name="job", subtask_name="")
 
@@ -709,7 +709,7 @@ class TestCheckPrinterUsesCachedFrameUrl:
             "action": "notify",
             "poll_interval": 10,
             "enabled_printers": None,
-            "external_url": "http://bambuddy:8000",
+            "snapshot_base_url": "http://bambuddy:8000",
         }
         status = MagicMock(state="RUNNING", task_name="job", subtask_name="")
 
@@ -729,8 +729,8 @@ class TestCheckPrinterUsesCachedFrameUrl:
         assert "Failed to capture snapshot" in svc._last_error
 
     @pytest.mark.asyncio
-    async def test_missing_external_url_skips_ml_call(self):
-        """Without external_url, Obico can't reach our cached-frame endpoint."""
+    async def test_missing_snapshot_address_skips_ml_call(self):
+        """Without a snapshot address, Obico can't reach our cached-frame endpoint."""
         svc = ObicoDetectionService()
         settings = {
             "enabled": True,
@@ -739,7 +739,7 @@ class TestCheckPrinterUsesCachedFrameUrl:
             "action": "notify",
             "poll_interval": 10,
             "enabled_printers": None,
-            "external_url": "",
+            "snapshot_base_url": "",
         }
         status = MagicMock(state="RUNNING", task_name="job", subtask_name="")
 
@@ -756,7 +756,7 @@ class TestCheckPrinterUsesCachedFrameUrl:
 
         mock_client.get.assert_not_called()
         assert svc._last_error is not None
-        assert "external_url" in svc._last_error
+        assert "bambuddy_internal_url" in svc._last_error
 
     @pytest.mark.asyncio
     async def test_successful_cycle_clears_previous_error(self):
@@ -774,7 +774,7 @@ class TestCheckPrinterUsesCachedFrameUrl:
             "action": "notify",
             "poll_interval": 10,
             "enabled_printers": None,
-            "external_url": "http://bambuddy:8000",
+            "snapshot_base_url": "http://bambuddy:8000",
         }
         status = MagicMock(state="RUNNING", task_name="job", subtask_name="")
 
@@ -822,7 +822,7 @@ class TestNoVerdictIsNotSafe:
         "action": "notify",
         "poll_interval": 10,
         "enabled_printers": None,
-        "external_url": "http://bambuddy:8000",
+        "snapshot_base_url": "http://bambuddy:8000",
     }
 
     @staticmethod
@@ -882,9 +882,9 @@ class TestNoVerdictIsNotSafe:
         assert "capture" in entry["error"].lower()
 
     @pytest.mark.asyncio
-    async def test_missing_external_url_reports_error(self):
+    async def test_missing_snapshot_address_reports_error(self):
         svc = ObicoDetectionService()
-        settings = {**self.SETTINGS, "external_url": ""}
+        settings = {**self.SETTINGS, "snapshot_base_url": ""}
         with patch.object(svc, "_capture_frame", new=AsyncMock(return_value=FAKE_JPEG)):
             await svc._check_printer(1, self._status(), settings)
 

+ 3 - 1
backend/tests/unit/test_outbound_url_ssrf_guards.py

@@ -253,13 +253,14 @@ def test_every_url_setting_is_either_guarded_or_explicitly_exempt():
     added per-incident rather than to the whole class of fields.
     """
     exempt = {
-        # Bambuddy's own public address, not a destination it requests. It is
+        # Bambuddy's own address, not a destination it requests. It is
         # rendered into notification bodies and OIDC redirect URIs, and handed
         # to Obico's ML server as the `img` parameter for that server to fetch
         # (obico_detection.py builds `{external_url}/api/v1/obico/cached-frame/
         # {nonce}`). Pointing it at a private address only breaks Bambuddy's own
         # links; it cannot make Bambuddy request anything it otherwise wouldn't.
         "external_url",
+        "bambuddy_internal_url",
         # Guarded by assert_safe_spoolman_url at each consumer (spoolman.py,
         # location_service.py, inventory.py, spoolbuddy.py,
         # spoolman_inventory.py) rather than in the schema, keeping its
@@ -609,6 +610,7 @@ GUARDED_BODY_URLS = {
 # Not a destination Bambuddy requests — no guard applies.
 NOT_A_FETCH_TARGET = {
     ("AppSettingsUpdate", "external_url"),  # Bambuddy's own address (see exempt list above)
+    ("AppSettingsUpdate", "bambuddy_internal_url"),
     ("AppSettingsUpdate", "ldap_server_url"),  # ldap://, handed to an LDAP client
     ("ProjectCreate", "url"),  # stored link, rendered in the UI, never fetched
     ("ProjectUpdate", "url"),

+ 49 - 0
frontend/src/__tests__/components/FailureDetectionSettings.test.tsx

@@ -23,6 +23,7 @@ const baseSettings = {
   include_beta_updates: false,
   obico_enabled: false,
   obico_ml_url: '',
+  bambuddy_internal_url: '',
   obico_ml_token: '',
   obico_sensitivity: 'medium',
   obico_action: 'notify',
@@ -60,6 +61,7 @@ describe('FailureDetectionSettings', () => {
     });
     expect(screen.getByText(/Obico ML API URL/i)).toBeInTheDocument();
     expect(screen.getByText(/Sensitivity/i)).toBeInTheDocument();
+    expect(screen.getByPlaceholderText('http://bambuddy:8000')).toBeDisabled();
   });
 
   it('test button calls the test-connection endpoint and shows success', async () => {
@@ -84,6 +86,53 @@ describe('FailureDetectionSettings', () => {
     expect(await screen.findByText(/ML API reachable/i)).toBeInTheDocument();
   });
 
+  it.each([
+    ['', 'http://print-server:8000'],
+    ['http://print-server:8000', ''],
+  ])('auto-saves a Bambuddy Internal URL change from "%s" to "%s"', async (initial, next) => {
+    let settings = { ...baseSettings, obico_enabled: true, bambuddy_internal_url: initial };
+    let saved: Record<string, unknown> | null = null;
+    server.use(
+      http.get('/api/v1/settings/', () => HttpResponse.json(settings)),
+      http.put('/api/v1/settings/', async ({ request }) => {
+        saved = (await request.json()) as Record<string, unknown>;
+        settings = { ...settings, ...saved };
+        return HttpResponse.json(settings);
+      }),
+    );
+    render(<FailureDetectionSettings />);
+    const input = await screen.findByPlaceholderText('http://bambuddy:8000');
+    await waitFor(() => {
+      expect(input).not.toBeDisabled();
+      expect(input).toHaveValue(initial);
+    });
+    await userEvent.clear(input);
+    if (next) await userEvent.type(input, next);
+    await waitFor(() => expect(saved?.bambuddy_internal_url).toBe(next), { timeout: 3000 });
+  });
+
+  it('flags a Bambuddy Internal URL without a scheme and does not save it', async () => {
+    const puts: Record<string, unknown>[] = [];
+    server.use(
+      http.get('/api/v1/settings/', () =>
+        HttpResponse.json({ ...baseSettings, obico_enabled: true, bambuddy_internal_url: '' }),
+      ),
+      http.put('/api/v1/settings/', async ({ request }) => {
+        const body = (await request.json()) as Record<string, unknown>;
+        puts.push(body);
+        return HttpResponse.json({ ...baseSettings, ...body });
+      }),
+    );
+    render(<FailureDetectionSettings />);
+    const input = await screen.findByPlaceholderText('http://bambuddy:8000');
+    await waitFor(() => expect(input).not.toBeDisabled());
+    await userEvent.type(input, 'bambuddy:8000');
+    expect(await screen.findByText(/Must start with http:\/\/ or https:\/\//)).toBeInTheDocument();
+    expect(input).toHaveAttribute('aria-invalid', 'true');
+    await new Promise((resolve) => setTimeout(resolve, 800));
+    expect(puts.filter((body) => 'bambuddy_internal_url' in body)).toEqual([]);
+  });
+
   describe('ML API token (#2733)', () => {
     const enabledWithToken = {
       ...baseSettings,

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

@@ -1590,6 +1590,7 @@ export interface AppSettings {
   ldap_default_group: string;
   obico_enabled: boolean;
   obico_ml_url: string;
+  bambuddy_internal_url: string;
   obico_ml_token: string;
   obico_sensitivity: 'low' | 'medium' | 'high';
   obico_action: 'notify' | 'pause' | 'pause_and_off';

+ 44 - 2
frontend/src/components/FailureDetectionSettings.tsx

@@ -11,6 +11,20 @@ import { NumberInput } from './NumberInput';
 
 type TestResult = { ok: boolean; message: string } | null;
 
+// The ML server fetches snapshots from this address, so it needs a scheme.
+// Empty falls back to External URL. Mirrors the backend validator, so a value
+// accepted here is never refused on save.
+function isValidInternalUrl(value: string): boolean {
+  const trimmed = value.trim();
+  if (trimmed === '') return true;
+  if (/\s/.test(trimmed) || !/^https?:\/\/[^\s/?#]/i.test(trimmed)) return false;
+  try {
+    return new URL(trimmed).hostname !== '';
+  } catch {
+    return false;
+  }
+}
+
 export function FailureDetectionSettings() {
   const { t } = useTranslation();
   const queryClient = useQueryClient();
@@ -18,6 +32,7 @@ export function FailureDetectionSettings() {
 
   const [enabled, setEnabled] = useState(false);
   const [mlUrl, setMlUrl] = useState('');
+  const [bambuddyInternalUrl, setBambuddyInternalUrl] = useState('');
   const [mlToken, setMlToken] = useState('');
   const [sensitivity, setSensitivity] = useState<'low' | 'medium' | 'high'>('medium');
   const [action, setAction] = useState<'notify' | 'pause' | 'pause_and_off'>('notify');
@@ -25,6 +40,7 @@ export function FailureDetectionSettings() {
   const [enabledPrinters, setEnabledPrinters] = useState<number[] | null>(null); // null = all
   const [testResult, setTestResult] = useState<TestResult>(null);
   const [initialized, setInitialized] = useState(false);
+  const internalUrlValid = isValidInternalUrl(bambuddyInternalUrl);
 
   const { data: settings } = useQuery({
     queryKey: ['settings'],
@@ -46,6 +62,7 @@ export function FailureDetectionSettings() {
     if (!settings) return;
     setEnabled(settings.obico_enabled ?? false);
     setMlUrl(settings.obico_ml_url ?? '');
+    setBambuddyInternalUrl(settings.bambuddy_internal_url ?? '');
     setMlToken(settings.obico_ml_token ?? '');
     setSensitivity(settings.obico_sensitivity ?? 'medium');
     setAction(settings.obico_action ?? 'notify');
@@ -66,6 +83,9 @@ export function FailureDetectionSettings() {
       api.updateSettings({
         obico_enabled: enabled,
         obico_ml_url: mlUrl,
+        // An invalid address is left out so the other fields still save; the
+        // field shows why it was not applied.
+        ...(internalUrlValid ? { bambuddy_internal_url: bambuddyInternalUrl.trim() } : {}),
         obico_ml_token: mlToken,
         obico_sensitivity: sensitivity,
         obico_action: action,
@@ -87,13 +107,14 @@ export function FailureDetectionSettings() {
     return (
       settings.obico_enabled !== enabled ||
       settings.obico_ml_url !== mlUrl ||
+      (internalUrlValid && (settings.bambuddy_internal_url ?? '') !== bambuddyInternalUrl.trim()) ||
       (settings.obico_ml_token ?? '') !== mlToken ||
       settings.obico_sensitivity !== sensitivity ||
       settings.obico_action !== action ||
       settings.obico_poll_interval !== pollInterval ||
       settings.obico_enabled_printers !== (enabledPrinters === null ? '' : JSON.stringify(enabledPrinters))
     );
-  }, [settings, initialized, enabled, mlUrl, mlToken, sensitivity, action, pollInterval, enabledPrinters]);
+  }, [settings, initialized, enabled, mlUrl, bambuddyInternalUrl, internalUrlValid, mlToken, sensitivity, action, pollInterval, enabledPrinters]);
 
   // Auto-save on change (debounced)
   useEffect(() => {
@@ -101,7 +122,7 @@ export function FailureDetectionSettings() {
     const id = setTimeout(() => saveMutation.mutate(), 500);
     return () => clearTimeout(id);
     // eslint-disable-next-line react-hooks/exhaustive-deps
-  }, [hasUnsavedChanges, enabled, mlUrl, mlToken, sensitivity, action, pollInterval, enabledPrinters]);
+  }, [hasUnsavedChanges, enabled, mlUrl, bambuddyInternalUrl, mlToken, sensitivity, action, pollInterval, enabledPrinters]);
 
   const handleTest = async () => {
     setTestResult(null);
@@ -215,6 +236,27 @@ export function FailureDetectionSettings() {
               )}
             </div>
 
+            <div>
+              <label className="block text-sm text-bambu-gray mb-1">
+                {t('failureDetection.bambuddyInternalUrl')}
+              </label>
+              <input
+                type="text"
+                value={bambuddyInternalUrl}
+                onChange={(e) => setBambuddyInternalUrl(e.target.value)}
+                placeholder="http://bambuddy:8000"
+                className="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-white text-sm"
+                disabled={!enabled}
+                aria-invalid={!internalUrlValid}
+              />
+              {!internalUrlValid && (
+                <p className="text-xs text-red-700 dark:text-red-400 mt-1">
+                  {t('failureDetection.bambuddyInternalUrlInvalid')}
+                </p>
+              )}
+              <p className="text-xs text-bambu-gray mt-1">{t('failureDetection.bambuddyInternalUrlHint')}</p>
+            </div>
+
             <div>
               <label className="block text-sm text-bambu-gray mb-1">
                 {t('failureDetection.sensitivity')}

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

@@ -7703,6 +7703,9 @@ export default {
     description: 'Überwacht Drucke über eine selbst gehostete Obico-ML-API und reagiert automatisch auf erkannte Fehldrucke.',
     mlUrl: 'Obico-ML-API-URL',
     mlUrlHint: 'Basis-URL deines selbst gehosteten Obico-ml_api-Containers (z. B. http://192.168.1.10:3333).',
+    bambuddyInternalUrl: 'Interne Bambuddy-URL (optional)',
+    bambuddyInternalUrlHint: 'Bambuddy-Adresse, die die ML-API erreichen kann, etwa ein Docker-Dienstname oder eine LAN-IP. Leer lassen, um die externe URL zu verwenden. Andere Integrationen verwenden weiterhin die externe URL.',
+    bambuddyInternalUrlInvalid: 'Muss mit http:// oder https:// beginnen (z. B. http://bambuddy:8000). Wird erst dann gespeichert.',
     mlToken: 'ML-API-Token (optional)',
     mlTokenPlaceholder: 'Leer lassen, wenn der Server ohne ML_API_TOKEN läuft',
     mlTokenHint: 'Muss mit der Umgebungsvariable ML_API_TOKEN deines Obico-ml_api-Containers übereinstimmen. Leer lassen, wenn der Container ohne Token läuft.',
@@ -7721,8 +7724,8 @@ export default {
     actionPauseOff: 'Pausieren und Strom abschalten',
     pollInterval: 'Prüfintervall (Sekunden)',
     pollIntervalHint: 'Wie oft jeder Drucker während eines laufenden Drucks geprüft wird. Minimum 5 s, Maximum 120 s.',
-    externalUrlMissing: 'Externe URL ist nicht gesetzt.',
-    externalUrlHint: 'Die ML-API ruft das Kamera-Snapshot per URL ab. Setze die externe URL in den allgemeinen Einstellungen, damit der ML-API-Container Bambuddy erreichen kann.',
+    externalUrlMissing: 'Keine Adresse gesetzt, über die die ML-API Snapshots abrufen kann.',
+    externalUrlHint: 'Setze hier die interne Bambuddy-URL oder die externe URL unter Einstellungen → Netzwerk, damit die ML-API Snapshots abrufen kann.',
     perPrinterTitle: 'Überwachte Drucker',
     perPrinterHint: 'Wähle, welche Drucker vom Erkennungsdienst überwacht werden.',
     monitorAll: 'Alle verbundenen Drucker überwachen',

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

@@ -7756,6 +7756,9 @@ export default {
     description: 'Monitor prints with a self-hosted Obico ML API and act on detected failures automatically.',
     mlUrl: 'Obico ML API URL',
     mlUrlHint: 'Base URL of your self-hosted Obico ml_api container (e.g. http://192.168.1.10:3333).',
+    bambuddyInternalUrl: 'Bambuddy Internal URL (optional)',
+    bambuddyInternalUrlHint: 'Bambuddy address reachable from the ML API, such as a Docker service name or LAN IP. Leave empty to use External URL. Other integrations still use External URL.',
+    bambuddyInternalUrlInvalid: 'Must start with http:// or https:// (for example http://bambuddy:8000). Not saved until it does.',
     mlToken: 'ML API Token (optional)',
     mlTokenPlaceholder: 'Leave empty if the server runs without ML_API_TOKEN',
     mlTokenHint: 'Must match the ML_API_TOKEN environment variable of your Obico ml_api container. Leave empty when the container runs without one.',
@@ -7774,8 +7777,8 @@ export default {
     actionPauseOff: 'Pause and cut power',
     pollInterval: 'Poll interval (seconds)',
     pollIntervalHint: 'How often to check each printer while it is printing. Minimum 5s, maximum 120s.',
-    externalUrlMissing: 'External URL is not set.',
-    externalUrlHint: 'The ML API fetches the camera snapshot by URL. Set the External URL in General settings so the ML API container can reach Bambuddy.',
+    externalUrlMissing: 'No address set for the ML API to fetch snapshots from.',
+    externalUrlHint: 'Set Bambuddy Internal URL here or External URL in Settings → Network so the ML API can fetch snapshots.',
     perPrinterTitle: 'Monitored Printers',
     perPrinterHint: 'Choose which printers the detection service watches.',
     monitorAll: 'Monitor all connected printers',

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

@@ -7711,6 +7711,9 @@ export default {
     description: 'Supervise las impresiones con una API de ML de Obico autoalojada y actúe automáticamente ante los fallos detectados.',
     mlUrl: 'URL de la API de ML de Obico',
     mlUrlHint: 'URL base de su contenedor ml_api de Obico autoalojado (p. ej. http://192.168.1.10:3333).',
+    bambuddyInternalUrl: 'URL interna de Bambuddy (opcional)',
+    bambuddyInternalUrlHint: 'Dirección de Bambuddy accesible desde la API de ML, como un nombre de servicio de Docker o una IP de la red local. Déjela vacía para usar la URL externa. Las demás integraciones seguirán usando la URL externa.',
+    bambuddyInternalUrlInvalid: 'Debe empezar por http:// o https:// (por ejemplo, http://bambuddy:8000). No se guarda hasta entonces.',
     mlToken: 'Token de la API de ML (opcional)',
     mlTokenPlaceholder: 'Déjelo vacío si el servidor funciona sin ML_API_TOKEN',
     mlTokenHint: 'Debe coincidir con la variable de entorno ML_API_TOKEN de su contenedor ml_api de Obico. Déjelo vacío si el contenedor funciona sin token.',
@@ -7729,8 +7732,8 @@ export default {
     actionPauseOff: 'Pausar y cortar la corriente',
     pollInterval: 'Intervalo de sondeo (segundos)',
     pollIntervalHint: 'Con qué frecuencia comprobar cada impresora mientras imprime. Mínimo 5 s, máximo 120 s.',
-    externalUrlMissing: 'La URL externa no está establecida.',
-    externalUrlHint: 'La API de ML obtiene la captura de la cámara por URL. Establezca la URL externa en los ajustes generales para que el contenedor de la API de ML pueda alcanzar Bambuddy.',
+    externalUrlMissing: 'No hay ninguna dirección desde la que la API de ML pueda obtener las capturas.',
+    externalUrlHint: 'Establezca aquí la URL interna de Bambuddy o la URL externa en Ajustes → Red para que la API de ML pueda obtener las capturas.',
     perPrinterTitle: 'Impresoras supervisadas',
     perPrinterHint: 'Elija qué impresoras vigila el servicio de detección.',
     monitorAll: 'Supervisar todas las impresoras conectadas',

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

@@ -7691,6 +7691,9 @@ export default {
     description: 'Surveille les impressions via une API ML Obico auto-hébergée et agit automatiquement sur les échecs détectés.',
     mlUrl: 'URL de l\'API ML Obico',
     mlUrlHint: 'URL de base de votre conteneur Obico ml_api auto-hébergé (ex. http://192.168.1.10:3333).',
+    bambuddyInternalUrl: 'URL interne de Bambuddy (facultatif)',
+    bambuddyInternalUrlHint: 'Adresse de Bambuddy accessible depuis l’API ML, par exemple un nom de service Docker ou une IP du réseau local. Laissez vide pour utiliser l’URL externe. Les autres intégrations continuent d’utiliser l’URL externe.',
+    bambuddyInternalUrlInvalid: 'Doit commencer par http:// ou https:// (par exemple http://bambuddy:8000). Non enregistrée tant que ce n’est pas le cas.',
     mlToken: 'Jeton de l\'API ML (facultatif)',
     mlTokenPlaceholder: 'Laissez vide si le serveur fonctionne sans ML_API_TOKEN',
     mlTokenHint: 'Doit correspondre à la variable d\'environnement ML_API_TOKEN de votre conteneur Obico ml_api. Laissez vide si le conteneur fonctionne sans jeton.',
@@ -7709,8 +7712,8 @@ export default {
     actionPauseOff: 'Pause et couper l\'alimentation',
     pollInterval: 'Intervalle de vérification (secondes)',
     pollIntervalHint: 'Fréquence de vérification de chaque imprimante pendant l\'impression. Minimum 5s, maximum 120s.',
-    externalUrlMissing: 'L\'URL externe n\'est pas définie.',
-    externalUrlHint: 'L\'API ML récupère la capture caméra par URL. Définissez l\'URL externe dans les paramètres généraux pour que le conteneur API ML puisse atteindre Bambuddy.',
+    externalUrlMissing: 'Aucune adresse n’est définie pour que l’API ML récupère les captures.',
+    externalUrlHint: 'Définissez ici l’URL interne de Bambuddy ou l’URL externe dans Paramètres → Réseau pour que l’API ML puisse récupérer les captures.',
     perPrinterTitle: 'Imprimantes surveillées',
     perPrinterHint: 'Choisissez quelles imprimantes le service de détection surveille.',
     monitorAll: 'Surveiller toutes les imprimantes connectées',

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

@@ -7690,6 +7690,9 @@ export default {
     description: 'Monitora le stampe tramite un\'API ML Obico auto-ospitata e agisce automaticamente sui guasti rilevati.',
     mlUrl: 'URL API ML Obico',
     mlUrlHint: 'URL base del tuo container Obico ml_api auto-ospitato (es. http://192.168.1.10:3333).',
+    bambuddyInternalUrl: 'URL interno di Bambuddy (facoltativo)',
+    bambuddyInternalUrlHint: 'Indirizzo di Bambuddy raggiungibile dall’API ML, ad esempio un nome di servizio Docker o un IP della rete locale. Lascia vuoto per usare l’URL esterno. Le altre integrazioni continuano a usare l’URL esterno.',
+    bambuddyInternalUrlInvalid: 'Deve iniziare con http:// o https:// (ad esempio http://bambuddy:8000). Non viene salvato finché non lo fa.',
     mlToken: 'Token API ML (facoltativo)',
     mlTokenPlaceholder: 'Lascia vuoto se il server funziona senza ML_API_TOKEN',
     mlTokenHint: 'Deve corrispondere alla variabile di ambiente ML_API_TOKEN del tuo container Obico ml_api. Lascia vuoto se il container funziona senza token.',
@@ -7708,8 +7711,8 @@ export default {
     actionPauseOff: 'Pausa e stacca corrente',
     pollInterval: 'Intervallo di controllo (secondi)',
     pollIntervalHint: 'Frequenza di controllo di ogni stampante durante la stampa. Minimo 5s, massimo 120s.',
-    externalUrlMissing: 'URL esterno non impostato.',
-    externalUrlHint: 'L\'API ML recupera l\'istantanea della telecamera tramite URL. Imposta l\'URL esterno nelle impostazioni generali in modo che il container API ML possa raggiungere Bambuddy.',
+    externalUrlMissing: 'Nessun indirizzo impostato da cui l’API ML possa scaricare le istantanee.',
+    externalUrlHint: 'Imposta qui l’URL interno di Bambuddy oppure l’URL esterno in Impostazioni → Rete, così l’API ML può recuperare le istantanee.',
     perPrinterTitle: 'Stampanti monitorate',
     perPrinterHint: 'Scegli quali stampanti il servizio di rilevamento deve monitorare.',
     monitorAll: 'Monitora tutte le stampanti connesse',

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

@@ -7704,6 +7704,9 @@ export default {
     description: 'セルフホストされた Obico ML API で印刷を監視し、検出された失敗に自動的に対応します。',
     mlUrl: 'Obico ML API の URL',
     mlUrlHint: 'セルフホストした Obico ml_api コンテナのベース URL (例: http://192.168.1.10:3333)。',
+    bambuddyInternalUrl: 'Bambuddyの内部URL(任意)',
+    bambuddyInternalUrlHint: 'Dockerのサービス名やLANのIPなど、ML APIから接続できるBambuddyのアドレス。空欄の場合は外部URLを使用します。他の連携は引き続き外部URLを使用します。',
+    bambuddyInternalUrlInvalid: 'http:// または https:// で始める必要があります(例: http://bambuddy:8000)。それまでは保存されません。',
     mlToken: 'ML API トークン(任意)',
     mlTokenPlaceholder: 'サーバーが ML_API_TOKEN なしで動作している場合は空のままにします',
     mlTokenHint: 'Obico ml_api コンテナの環境変数 ML_API_TOKEN と一致させる必要があります。コンテナがトークンなしで動作している場合は空のままにしてください。',
@@ -7722,8 +7725,8 @@ export default {
     actionPauseOff: '一時停止して電源を切る',
     pollInterval: 'ポーリング間隔(秒)',
     pollIntervalHint: '印刷中に各プリンターをチェックする頻度。最小 5 秒、最大 120 秒。',
-    externalUrlMissing: '外部URLが設定されていません。',
-    externalUrlHint: 'ML APIはURLでカメラスナップショットを取得します。MLAPIコンテナがBambuddyに到達できるよう、一般設定で外部URLを設定してください。',
+    externalUrlMissing: 'ML APIがスナップショットを取得するアドレスが設定されていません。',
+    externalUrlHint: 'ML APIがスナップショットを取得できるように、ここでBambuddyの内部URLを設定するか、設定 → ネットワークで外部URLを設定してください。',
     perPrinterTitle: '監視対象プリンター',
     perPrinterHint: '検出サービスが監視するプリンターを選択します。',
     monitorAll: '接続されているすべてのプリンターを監視',

+ 5 - 2
frontend/src/i18n/locales/ko.ts

@@ -7146,6 +7146,9 @@ export default {
     description: '자체 호스팅 Obico ML API로 인쇄를 모니터링하고 감지된 실패에 자동으로 조치합니다.',
     mlUrl: 'Obico ML API URL',
     mlUrlHint: '자체 호스팅 Obico ml_api 컨테이너의 기본 URL (예: http://192.168.1.10:3333).',
+    bambuddyInternalUrl: 'Bambuddy 내부 URL (선택 사항)',
+    bambuddyInternalUrlHint: 'Docker 서비스 이름이나 LAN IP 등 ML API에서 접근할 수 있는 Bambuddy 주소입니다. 비워 두면 외부 URL을 사용합니다. 다른 연동은 계속 외부 URL을 사용합니다.',
+    bambuddyInternalUrlInvalid: 'http:// 또는 https://로 시작해야 합니다(예: http://bambuddy:8000). 그 전까지는 저장되지 않습니다.',
     mlToken: 'ML API 토큰 (선택 사항)',
     mlTokenPlaceholder: '서버가 ML_API_TOKEN 없이 실행 중이면 비워 두세요',
     mlTokenHint: 'Obico ml_api 컨테이너의 ML_API_TOKEN 환경 변수와 일치해야 합니다. 컨테이너가 토큰 없이 실행 중이면 비워 두세요.',
@@ -7164,8 +7167,8 @@ export default {
     actionPauseOff: '일시 중지 후 전원 끄기',
     pollInterval: '폴링 간격 (초)',
     pollIntervalHint: '인쇄 중 각 프린터를 확인하는 빈도. 최소 5초, 최대 120초.',
-    externalUrlMissing: '외부 URL이 설정되지 않았습니다.',
-    externalUrlHint: 'ML API가 URL을 통해 카메라 스냅샷을 가져옵니다. ML API 컨테이너가 Bambuddy에 도달할 수 있도록 일반 설정에서 외부 URL을 설정하세요.',
+    externalUrlMissing: 'ML API가 스냅샷을 가져올 주소가 설정되지 않았습니다.',
+    externalUrlHint: 'ML API가 스냅샷을 가져올 수 있도록 여기에서 Bambuddy 내부 URL을 설정하거나 설정 → 네트워크에서 외부 URL을 설정하세요.',
     perPrinterTitle: '모니터링되는 프린터',
     perPrinterHint: '감지 서비스가 감시할 프린터를 선택하세요.',
     monitorAll: '연결된 모든 프린터 모니터링',

+ 5 - 2
frontend/src/i18n/locales/nl.ts

@@ -7754,6 +7754,9 @@ export default {
     description: 'Bewaak afdrukken met een zelf-gehoste Obico ML API en voer automatisch acties uit bij gedetecteerde fouten.',
     mlUrl: 'Obico ML API-URL',
     mlUrlHint: 'Basis-URL van je zelf-gehoste Obico ml_api-container (bijv. http://192.168.1.10:3333).',
+    bambuddyInternalUrl: 'Interne Bambuddy-URL (optioneel)',
+    bambuddyInternalUrlHint: 'Bambuddy-adres dat bereikbaar is vanuit de ML-API, zoals een Docker-servicenaam of LAN-IP. Laat leeg om de externe URL te gebruiken. Andere integraties blijven de externe URL gebruiken.',
+    bambuddyInternalUrlInvalid: 'Moet beginnen met http:// of https:// (bijvoorbeeld http://bambuddy:8000). Wordt pas dan opgeslagen.',
     mlToken: 'ML API-token (optioneel)',
     mlTokenPlaceholder: 'Laat leeg als de server zonder ML_API_TOKEN draait',
     mlTokenHint: 'Moet overeenkomen met de omgevingsvariabele ML_API_TOKEN van je Obico ml_api-container. Laat leeg wanneer de container zonder token draait.',
@@ -7772,8 +7775,8 @@ export default {
     actionPauseOff: 'Pauzeren en stroom uitschakelen',
     pollInterval: 'Controle-interval (seconden)',
     pollIntervalHint: 'Hoe vaak elke printer tijdens het afdrukken wordt gecontroleerd. Minimaal 5s, maximaal 120s.',
-    externalUrlMissing: 'Externe URL is niet ingesteld.',
-    externalUrlHint: 'De ML API haalt de cameramomentopname via een URL op. Stel de Externe URL in bij Algemene instellingen zodat de ML API-container Bambuddy kan bereiken.',
+    externalUrlMissing: 'Er is geen adres ingesteld waarvandaan de ML-API snapshots kan ophalen.',
+    externalUrlHint: 'Stel hier de interne Bambuddy-URL in, of de externe URL onder Instellingen → Netwerk, zodat de ML-API snapshots kan ophalen.',
     perPrinterTitle: 'Bewaakte printers',
     perPrinterHint: 'Kies welke printers door de detectieservice worden bewaakt.',
     monitorAll: 'Alle verbonden printers bewaken',

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

@@ -7690,6 +7690,9 @@ export default {
     description: 'Monitora impressões via API ML do Obico auto-hospedada e age automaticamente em falhas detectadas.',
     mlUrl: 'URL da API ML do Obico',
     mlUrlHint: 'URL base do seu contêiner Obico ml_api auto-hospedado (ex.: http://192.168.1.10:3333).',
+    bambuddyInternalUrl: 'URL interna do Bambuddy (opcional)',
+    bambuddyInternalUrlHint: 'Endereço do Bambuddy acessível pela API de ML, como um nome de serviço do Docker ou um IP da rede local. Deixe vazio para usar a URL externa. As outras integrações continuam usando a URL externa.',
+    bambuddyInternalUrlInvalid: 'Deve começar com http:// ou https:// (por exemplo, http://bambuddy:8000). Não é salvo até lá.',
     mlToken: 'Token da API ML (opcional)',
     mlTokenPlaceholder: 'Deixe vazio se o servidor for executado sem ML_API_TOKEN',
     mlTokenHint: 'Deve corresponder à variável de ambiente ML_API_TOKEN do seu contêiner Obico ml_api. Deixe vazio se o contêiner for executado sem token.',
@@ -7708,8 +7711,8 @@ export default {
     actionPauseOff: 'Pausar e cortar energia',
     pollInterval: 'Intervalo de verificação (segundos)',
     pollIntervalHint: 'Frequência de verificação de cada impressora durante a impressão. Mínimo 5s, máximo 120s.',
-    externalUrlMissing: 'URL externa não definida.',
-    externalUrlHint: 'A API ML busca a captura da câmera por URL. Defina a URL externa nas configurações gerais para que o contêiner da API ML possa alcançar o Bambuddy.',
+    externalUrlMissing: 'Nenhum endereço definido para a API de ML buscar as capturas.',
+    externalUrlHint: 'Defina aqui a URL interna do Bambuddy ou a URL externa em Configurações → Rede para que a API de ML possa buscar as capturas.',
     perPrinterTitle: 'Impressoras monitoradas',
     perPrinterHint: 'Escolha quais impressoras o serviço de detecção monitora.',
     monitorAll: 'Monitorar todas as impressoras conectadas',

+ 5 - 2
frontend/src/i18n/locales/ru.ts

@@ -7327,6 +7327,9 @@ export default {
     description: "Контролируйте печать через собственный сервер Obico ML API и автоматически реагируйте на обнаруженные сбои.",
     mlUrl: "URL Obico ML API",
     mlUrlHint: "Базовый URL собственного контейнера Obico ml_api, например http://192.168.1.10:3333.",
+    bambuddyInternalUrl: "Внутренний URL Bambuddy (необязательно)",
+    bambuddyInternalUrlHint: "Адрес Bambuddy, доступный для ML API, например имя сервиса Docker или IP в локальной сети. Оставьте пустым, чтобы использовать внешний URL. Другие интеграции продолжат использовать внешний URL.",
+    bambuddyInternalUrlInvalid: "Должен начинаться с http:// или https:// (например, http://bambuddy:8000). До этого не сохраняется.",
     mlToken: "Токен ML API (необязательно)",
     mlTokenPlaceholder: "Оставьте пустым, если сервер работает без ML_API_TOKEN",
     mlTokenHint: "Должен совпадать с переменной окружения ML_API_TOKEN вашего контейнера Obico ml_api. Оставьте пустым, если контейнер работает без токена.",
@@ -7345,8 +7348,8 @@ export default {
     actionPauseOff: "Приостановить печать и отключить питание",
     pollInterval: "Интервал проверки (секунды)",
     pollIntervalHint: "Как часто проверять каждый принтер во время печати. Минимум 5 с, максимум 120 с.",
-    externalUrlMissing: "Внешний URL не задан.",
-    externalUrlHint: "ML API получает снимок камеры по URL. Укажите внешний URL в общих настройках, чтобы контейнер ML API мог обращаться к Bambuddy.",
+    externalUrlMissing: "Не задан адрес, с которого ML API может получать снимки.",
+    externalUrlHint: "Укажите здесь внутренний URL Bambuddy или внешний URL в разделе Настройки → Сеть, чтобы ML API мог получать снимки.",
     perPrinterTitle: "Контролируемые принтеры",
     perPrinterHint: "Выберите принтеры, за которыми будет следить служба обнаружения.",
     monitorAll: "Контролировать все подключённые принтеры",

+ 5 - 2
frontend/src/i18n/locales/sv.ts

@@ -7752,6 +7752,9 @@ errors: {
     description: 'Övervaka utskrifter med ett självhostat Obico ML-API och agera automatiskt på upptäckta fel.',
     mlUrl: 'Obico ML-API-URL',
     mlUrlHint: 'Bas-URL för din självhostade Obico ml_api-container (t.ex. http://192.168.1.10:3333).',
+    bambuddyInternalUrl: 'Intern URL för Bambuddy (valfritt)',
+    bambuddyInternalUrlHint: 'Bambuddy-adress som ML-API:et kan nå, till exempel ett Docker-tjänstnamn eller en LAN-IP. Lämna tomt för att använda extern URL. Andra integrationer fortsätter att använda extern URL.',
+    bambuddyInternalUrlInvalid: 'Måste börja med http:// eller https:// (till exempel http://bambuddy:8000). Sparas inte förrän dess.',
     mlToken: 'ML-API-token (valfritt)',
     mlTokenPlaceholder: 'Lämna tomt om servern körs utan ML_API_TOKEN',
     mlTokenHint: 'Måste matcha ML_API_TOKEN-miljövariabeln för din Obico ml_api-container. Lämna tomt när containern körs utan en.',
@@ -7770,8 +7773,8 @@ errors: {
     actionPauseOff: 'Pausa och bryt ström',
     pollInterval: 'Avläsningsintervall (sekunder)',
     pollIntervalHint: 'Hur ofta varje skrivare ska kontrollera medan den skriver ut. Minimum 5s, maximum 120s.',
-    externalUrlMissing: 'Extern URL är inte inställd.',
-    externalUrlHint: 'ML-API:et hämtar kamerans ögonblicksbild via URL. Sätt den externa URL:en i Allmänna inställningar så att ML-API-containern kan nå Bambuddy.',
+    externalUrlMissing: 'Ingen adress angiven som ML-API:et kan hämta ögonblicksbilder från.',
+    externalUrlHint: 'Ange intern URL för Bambuddy här eller extern URL under Inställningar → Nätverk så att ML-API:et kan hämta bilder.',
     perPrinterTitle: 'Övervakade skrivare',
     perPrinterHint: 'Välj vilka skrivare som övervakningstjänsten ska bevaka.',
     monitorAll: 'Övervaka alla anslutna skrivare',

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

@@ -7640,6 +7640,9 @@ export default {
     description: 'Baskıları kendi barındırılan bir Obico ML API ile izle ve algılanan başarısızlıklara otomatik olarak yanıt ver.',
     mlUrl: 'Obico ML API URL\'si',
     mlUrlHint: 'Kendi barındırılan Obico ml_api konteynerinizin temel URL\'si (örn. http://192.168.1.10:3333).',
+    bambuddyInternalUrl: 'Bambuddy dahili URL’si (isteğe bağlı)',
+    bambuddyInternalUrlHint: 'Docker hizmet adı veya yerel ağ IP’si gibi ML API’nin erişebildiği Bambuddy adresi. Harici URL’yi kullanmak için boş bırakın. Diğer entegrasyonlar Harici URL’yi kullanmaya devam eder.',
+    bambuddyInternalUrlInvalid: 'http:// veya https:// ile başlamalıdır (örneğin http://bambuddy:8000). O zamana kadar kaydedilmez.',
     mlToken: 'ML API Belirteci (isteğe bağlı)',
     mlTokenPlaceholder: 'Sunucu ML_API_TOKEN olmadan çalışıyorsa boş bırakın',
     mlTokenHint: 'Obico ml_api konteynerinizin ML_API_TOKEN ortam değişkeniyle eşleşmelidir. Konteyner belirteç olmadan çalışıyorsa boş bırakın.',
@@ -7658,8 +7661,8 @@ export default {
     actionPauseOff: 'Duraklat ve gücü kes',
     pollInterval: 'Sorgulama aralığı (saniye)',
     pollIntervalHint: 'Baskı yaparken her yazıcının ne sıklıkta kontrol edileceği. Minimum 5sn, maksimum 120sn.',
-    externalUrlMissing: 'Harici URL ayarlanmamış.',
-    externalUrlHint: 'ML API, kamera anlık görüntüsünü URL üzerinden alır. ML API konteyneri Bambuddy\'ye erişebilsin diye Genel ayarlarda Harici URL\'yi ayarlayın.',
+    externalUrlMissing: 'ML API’nin anlık görüntüleri alacağı bir adres ayarlanmamış.',
+    externalUrlHint: 'ML API’nin anlık görüntüleri alabilmesi için burada Bambuddy dahili URL’sini veya Ayarlar → Ağ bölümünde Harici URL’yi ayarlayın.',
     perPrinterTitle: 'İzlenen Yazıcılar',
     perPrinterHint: 'Algılama servisinin hangi yazıcıları izleyeceğini seçin.',
     monitorAll: 'Tüm bağlı yazıcıları izle',

+ 5 - 2
frontend/src/i18n/locales/uk.ts

@@ -7744,6 +7744,9 @@ export default {
     description: "Відстежуйте друк за допомогою самостійно розгорнутого Obico ML API та автоматично реагуйте на виявлені помилки.",
     mlUrl: "URL Obico ML API",
     mlUrlHint: "База URL вашого контейнера ml_api, розміщеного на власному хості Obico (наприклад, http://192.168.1.10:3333).",
+    bambuddyInternalUrl: "Внутрішня URL-адреса Bambuddy (необов’язково)",
+    bambuddyInternalUrlHint: "Адреса Bambuddy, доступна для ML API, наприклад ім’я сервісу Docker або IP у локальній мережі. Залиште порожнім, щоб використовувати зовнішню URL-адресу. Інші інтеграції й надалі використовуватимуть зовнішню URL-адресу.",
+    bambuddyInternalUrlInvalid: "Має починатися з http:// або https:// (наприклад, http://bambuddy:8000). Доти не зберігається.",
     mlToken: "Токен ML API (необов'язково)",
     mlTokenPlaceholder: "Залиште порожнім, якщо сервер працює без ML_API_TOKEN",
     mlTokenHint: "Має збігатися зі змінною середовища ML_API_TOKEN вашого контейнера Obico ml_api. Залиште порожнім, якщо контейнер працює без токена.",
@@ -7762,8 +7765,8 @@ export default {
     actionPauseOff: "Призупинити друк і вимкнути живлення",
     pollInterval: "Інтервал опитування (секунди)",
     pollIntervalHint: "Як часто перевіряти кожен принтер під час друку. Мінімум 5 с, максимум 120 с.",
-    externalUrlMissing: "Зовнішній URL не встановлено.",
-    externalUrlHint: "ML API отримує знімок із камери за URL. Укажіть зовнішній URL у загальних налаштуваннях, щоб контейнер ML API міг підключитися до Bambuddy.",
+    externalUrlMissing: "Не задано адресу, з якої ML API може отримувати знімки.",
+    externalUrlHint: "Укажіть тут внутрішню URL-адресу Bambuddy або зовнішню URL-адресу в розділі Налаштування → Мережа, щоб ML API міг отримувати знімки.",
     perPrinterTitle: "Принтери під спостереженням",
     perPrinterHint: "Виберіть, які принтери перевіряє служба виявлення.",
     monitorAll: "Відстежувати всі підключені принтери",

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

@@ -7688,6 +7688,9 @@ export default {
     description: '通过自托管的 Obico ML API 监控打印,并对检测到的故障自动采取行动。',
     mlUrl: 'Obico ML API 地址',
     mlUrlHint: '您自托管的 Obico ml_api 容器的基础 URL(例如 http://192.168.1.10:3333)。',
+    bambuddyInternalUrl: 'Bambuddy 内部 URL(可选)',
+    bambuddyInternalUrlHint: 'ML API 可以访问的 Bambuddy 地址,例如 Docker 服务名称或局域网 IP。留空则使用外部 URL。其他集成仍使用外部 URL。',
+    bambuddyInternalUrlInvalid: '必须以 http:// 或 https:// 开头(例如 http://bambuddy:8000)。在此之前不会保存。',
     mlToken: 'ML API 令牌(可选)',
     mlTokenPlaceholder: '如果服务器未设置 ML_API_TOKEN,请留空',
     mlTokenHint: '必须与您的 Obico ml_api 容器的 ML_API_TOKEN 环境变量一致。如果容器未使用令牌,请留空。',
@@ -7706,8 +7709,8 @@ export default {
     actionPauseOff: '暂停并切断电源',
     pollInterval: '检查间隔(秒)',
     pollIntervalHint: '打印过程中每台打印机的检查频率。最小 5 秒,最大 120 秒。',
-    externalUrlMissing: '未设置外部 URL。',
-    externalUrlHint: 'ML API 通过 URL 获取摄像头快照。请在通用设置中设置外部 URL,以便 ML API 容器可以访问 Bambuddy。',
+    externalUrlMissing: '尚未设置 ML API 获取快照的地址。',
+    externalUrlHint: '请在此设置 Bambuddy 内部 URL,或在设置 → 网络中设置外部 URL,以便 ML API 获取快照。',
     perPrinterTitle: '监控的打印机',
     perPrinterHint: '选择检测服务要监视哪些打印机。',
     monitorAll: '监控所有已连接的打印机',

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

@@ -7688,6 +7688,9 @@ export default {
     description: '透過自託管的 Obico ML API 監控列印,並對偵測到的故障自動採取行動。',
     mlUrl: 'Obico ML API 地址',
     mlUrlHint: '您自託管的 Obico ml_api 容器的基礎 URL(例如 http://192.168.1.10:3333)。',
+    bambuddyInternalUrl: 'Bambuddy 內部 URL(選填)',
+    bambuddyInternalUrlHint: 'ML API 可以存取的 Bambuddy 位址,例如 Docker 服務名稱或區域網路 IP。留空則使用外部 URL。其他整合仍使用外部 URL。',
+    bambuddyInternalUrlInvalid: '必須以 http:// 或 https:// 開頭(例如 http://bambuddy:8000)。在此之前不會儲存。',
     mlToken: 'ML API 權杖(選填)',
     mlTokenPlaceholder: '如果伺服器未設定 ML_API_TOKEN,請留空',
     mlTokenHint: '必須與您的 Obico ml_api 容器的 ML_API_TOKEN 環境變數一致。如果容器未使用權杖,請留空。',
@@ -7706,8 +7709,8 @@ export default {
     actionPauseOff: '暫停並切斷電源',
     pollInterval: '檢查間隔(秒)',
     pollIntervalHint: '列印過程中每臺印表機的檢查頻率。最小 5 秒,最大 120 秒。',
-    externalUrlMissing: '尚未設定外部 URL。',
-    externalUrlHint: 'ML API 透過 URL 擷取攝影機快照。請在一般設定中設定外部 URL,讓 ML API 容器可以連線到 Bambuddy。',
+    externalUrlMissing: '尚未設定 ML API 擷取快照的位址。',
+    externalUrlHint: '請在此設定 Bambuddy 內部 URL,或在設定 → 網路中設定外部 URL,讓 ML API 能取得快照。',
     perPrinterTitle: '監控的印表機',
     perPrinterHint: '選擇檢測服務要監視哪些印表機。',
     monitorAll: '監控所有已連線的印表機',

File diff suppressed because it is too large
+ 0 - 0
static/assets/AdditionalSection-Bcoa_hnW.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/AmsUnitCard-DLM3L08d.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ArchivesPage-MaOGuDlA.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/AreaChart-J0I0YlYN.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/AssignToAmsModal-kr-0EzwV.js


+ 0 - 1
static/assets/Button-pwVKPiYn.js

@@ -1 +0,0 @@
-import{C as e}from"./client-BdOyoZJz.js";var t=e();function n({variant:e=`primary`,size:n=`md`,className:r=``,children:i,...a}){return(0,t.jsx)(`button`,{className:`inline-flex items-center justify-center font-medium rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-bambu-dark disabled:opacity-50 disabled:cursor-not-allowed ${{primary:`bg-bambu-green hover:bg-bambu-green-light text-white focus:ring-bambu-green`,secondary:`bg-bambu-dark-tertiary hover:bg-bambu-gray-dark text-white focus:ring-bambu-gray`,danger:`bg-red-600 hover:bg-red-700 text-white focus:ring-red-500`,ghost:`bg-transparent hover:bg-bambu-dark-tertiary text-bambu-gray-light hover:text-white`}[e]} ${{sm:`px-3 py-1.5 text-sm gap-1.5 min-h-[44px] md:min-h-0`,md:`px-4 py-2 text-sm gap-2 min-h-[44px] md:min-h-0`,lg:`px-6 py-3 text-base gap-2 min-h-[48px] md:min-h-0`}[n]} ${r}`,...a,children:i})}export{n as t};

File diff suppressed because it is too large
+ 0 - 0
static/assets/CamWallPage-C1rIEzhG.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/CameraDiagnoseModal-DWbtempu.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/CameraPage-C1mWeQfG.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/CameraWall-Br9MDE6G.js


+ 0 - 1
static/assets/Card-OUAj0Ayh.js

@@ -1 +0,0 @@
-import{i as e}from"./chunk-aKtaBQYM.js";import{C as t,D as n}from"./client-BdOyoZJz.js";var r=e(n(),1),i=t(),a=(0,r.createContext)(`normal`);function o({density:e,children:t}){return(0,i.jsx)(a.Provider,{value:e,children:t})}function s({children:e,className:t=``,onClick:n,onContextMenu:r,...a}){return(0,i.jsx)(`div`,{className:`bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary card-shadow ${n?`cursor-pointer`:``} ${t}`,onClick:n,onContextMenu:r,...a,children:e})}function c({children:e,className:t=``,dense:n}){let o=(0,r.useContext)(a)===`dense`;return(0,i.jsx)(`div`,{className:`${n??o?`px-4 py-2.5`:`px-6 py-4`} border-b border-bambu-dark-tertiary ${t}`,children:e})}function l({children:e,className:t=``,dense:n}){let o=(0,r.useContext)(a)===`dense`;return(0,i.jsx)(`div`,{className:`${n??o?`p-4`:`p-6`} ${t}`,children:e})}export{c as i,l as n,o as r,s as t};

File diff suppressed because it is too large
+ 0 - 0
static/assets/CartesianChart-DcboTNtX.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ColumnConfigModal-B9cmtxVm.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ConfigureAmsSlotModal-C4N9v6PJ.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ConnectAuthorizePage-DtVlpfFV.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ContextMenu-CqE3-J0R.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/CopyButton-CyNT06yS.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ErrorBarContext-6wvnfk_q.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ExternalLinkPage-CM4__1rh.js


File diff suppressed because it is too large
+ 0 - 1
static/assets/FileManagerPage-Kp8jeXP4.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/FileUploadModal-hFWmsB_i.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/FinancePage-BfhaugR1.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/GCodeViewerPage-C24Va31U.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/GcodeToolpathViewer-D4M17KI8.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/GroupEditPage-i9TLWDyl.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ImagePreviewModal-BcWeiOrd.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/InventoryPage-BWTPGneX.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/LibraryTrashPage-Ck7R9blU.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/LoginPage-CXIN3BLj.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/MaintenancePage-BAMWk5DI.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ModelSourcesPage-C9OvAi9p.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/NotificationsPage-5GOJDRkw.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/OrbitControls-Bbqf07B-.js


+ 0 - 1
static/assets/OverlayFrame-BUFy7NI1.js

@@ -1 +0,0 @@
-import{i as e}from"./chunk-aKtaBQYM.js";import{C as t,D as n,a as r}from"./client-BdOyoZJz.js";var i=e(n(),1);function a(e,t=null,n=0){let[a,o]=(0,i.useState)(null);return(0,i.useEffect)(()=>{if(!e)return;let i=new AbortController,a;return r.getOverlayLogo(t,i.signal).then(e=>{i.signal.aborted||(e?(a=URL.createObjectURL(e),o({url:a,token:t,revision:n})):o(null))}).catch(()=>{i.signal.aborted||o(null)}),()=>{i.abort(),a&&URL.revokeObjectURL(a)}},[e,t,n]),e&&a?.token===t&&a.revision===n?a.url:null}var o={logo:!1,logoRevision:0,from:``,to:``};function s(e){return/^#[0-9a-f]{6}$/i.test(e)}function c(e,t){return e&&t&&s(e)&&s(t)?`linear-gradient(to right, ${e}, ${t})`:void 0}function l(e){if(e)return{backgroundImage:e,backgroundClip:`text`,WebkitBackgroundClip:`text`,color:`transparent`,WebkitTextFillColor:`transparent`}}var u={landscape:{width:1920,height:1080},portrait:{width:1080,height:1920}},d=t();function f({layout:e,preview:t=!1,children:n}){let r=(0,i.useRef)(null),[a,o]=(0,i.useState)(0),{width:s,height:c}=u[e];(0,i.useLayoutEffect)(()=>{let e=r.current;if(!e)return;let t=()=>o(Math.min(e.clientWidth/s,e.clientHeight/c));if(t(),typeof ResizeObserver>`u`)return window.addEventListener(`resize`,t),()=>window.removeEventListener(`resize`,t);let n=new ResizeObserver(t);return n.observe(e),()=>n.disconnect()},[s,c]);let l={width:s,height:c,"--overlay-vmin":`10.8px`,"--overlay-height":`${c}px`,position:`absolute`,top:`50%`,left:`50%`,transform:`translate(-50%, -50%) scale(${a})`,transformOrigin:`center`};return(0,d.jsx)(`div`,{ref:r,className:t?`relative w-full overflow-hidden`:`fixed inset-0 overflow-hidden`,style:t?{aspectRatio:`${s} / ${c}`}:void 0,children:(0,d.jsx)(`div`,{style:l,children:n})})}export{c as a,s as i,u as n,l as o,o as r,a as s,f as t};

File diff suppressed because it is too large
+ 0 - 1
static/assets/PdfPreviewModal-CRMSWXkI.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/PhotoGalleryModal-BxTCJXU3.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/PrinterLocationsPage-D2AqeJ9i.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/PrintersPage-zDUFDj5q.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ProfilesPage-C0jHVdln.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ProjectDetailPage-CaZEFrXK.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ProjectsPage-C2aXY-5x.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/QueryClientProvider-Clbyr4Jg.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/QueuePage-DTh2aCRG.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ReferenceLine-l3CI8D5f.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/RichTextEditor-CtyYZFiu.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/SettingsPage-DOroBYzD.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/SetupPage-hUHebIlA.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/SliceModal-DilRo4MU.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/SpoolBuddyAmsPage-CCeWMoIm.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/SpoolBuddyCalibrationPage-BPEaykbd.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/SpoolBuddyDashboard-B_0MmvgI.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/SpoolBuddyInventoryPage-BmxJPbmk.js


File diff suppressed because it is too large
+ 0 - 1
static/assets/SpoolBuddySettingsPage-DKia6Rid.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/SpoolBuddyWriteTagPage-CMlhWgSL.js


+ 0 - 1
static/assets/SpoolIcon-OZKzbEc8.js

@@ -1 +0,0 @@
-import{i as e}from"./chunk-aKtaBQYM.js";import{C as t,D as n}from"./client-BdOyoZJz.js";import{n as r}from"./filamentSwatchHelpers-D3oXQSGk.js";import{t as i}from"./spoolPaint-CrvpHqdD.js";var a=e(n(),1),o=t();function s({spool:e,inset:t,size:n,border:i}){let s=n>=80?`card`:`preview`,c=(0,a.useMemo)(()=>r({effectSize:s,rgba:e.rgba,extraColors:e.extra_colors,effectType:e.effect_type,subtype:e.subtype}),[s,e.rgba,e.extra_colors,e.effect_type,e.subtype]),l=`${t*100}%`;return(0,o.jsx)(`div`,{"data-testid":`spool-paint`,className:`absolute rounded-full box-border`,style:{top:l,left:l,right:l,bottom:l,...c,backgroundPosition:`center`,border:i}})}function c({inset:e}){let t=`${e*100}%`;return(0,o.jsx)(`div`,{"data-testid":`spool-shade`,className:`absolute rounded-full`,style:{top:t,left:t,right:t,bottom:t,backgroundColor:`rgba(0, 0, 0, 0.15)`}})}function l({color:e,isEmpty:t,size:n=32,spool:r}){return t?(0,o.jsx)(`div`,{className:`rounded-full border-2 border-dashed border-zinc-500 flex items-center justify-center`,style:{width:n,height:n},children:(0,o.jsx)(`div`,{className:`w-2 h-2 rounded-full bg-zinc-600`})}):i(r)?(0,o.jsxs)(`div`,{className:`relative shrink-0`,style:{width:n,height:n},children:[(0,o.jsx)(s,{spool:r,size:n,inset:2/32,border:`${1.5*n/32}px solid rgba(255, 255, 255, 0.7)`}),(0,o.jsx)(c,{inset:5/32})]}):(0,o.jsxs)(`svg`,{width:n,height:n,viewBox:`0 0 32 32`,children:[(0,o.jsx)(`circle`,{cx:`16`,cy:`16`,r:`14`,fill:e,stroke:`white`,strokeWidth:`1.5`,strokeOpacity:`0.7`}),(0,o.jsx)(`circle`,{cx:`16`,cy:`16`,r:`11`,fill:e,style:{filter:`brightness(0.85)`}})]})}export{s as n,c as r,l as t};

File diff suppressed because it is too large
+ 0 - 1
static/assets/SpreadsheetPreviewModal-WUjrw07T.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/StatsPage-D0cqrND3.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/StreamOverlayPage-CA9KpVK8.css


File diff suppressed because it is too large
+ 0 - 0
static/assets/StreamOverlayPage-DWOhiuT-.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/SystemInfoPage-ClAReSss.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ToastContext-VFOrCCoA.js


+ 0 - 1
static/assets/Toggle-CwMiiK0g.js

@@ -1 +0,0 @@
-import{C as e}from"./client-BdOyoZJz.js";var t=e();function n({checked:e,onChange:n,disabled:r}){return(0,t.jsx)(`button`,{type:`button`,role:`switch`,"aria-checked":e,disabled:r,onClick:t=>{t.preventDefault(),t.stopPropagation(),r||n(!e)},className:`relative inline-flex w-11 h-7 md:w-9 md:h-5 rounded-full transition-colors flex-shrink-0 focus:outline-none focus:ring-2 focus:ring-bambu-green focus:ring-offset-2 focus:ring-offset-bambu-dark ${r?`bg-bambu-dark-tertiary/50 cursor-not-allowed opacity-50`:e?`bg-bambu-green cursor-pointer`:`bg-bambu-dark-tertiary cursor-pointer hover:bg-bambu-dark-tertiary/80`}`,children:(0,t.jsx)(`span`,{className:`pointer-events-none absolute top-[3px] md:top-[2px] left-[3px] md:left-[2px] w-5 h-5 md:w-4 md:h-4 bg-white rounded-full shadow transition-transform duration-200 ease-in-out ${e?`translate-x-4`:`translate-x-0`}`})})}export{n as t};

+ 0 - 1
static/assets/arrow-down-C4OBNDqJ.js

@@ -1 +0,0 @@
-import{t as e}from"./createLucideIcon-Yo2YoYjm.js";var t=e(`arrow-down`,[[`path`,{d:`M12 5v14`,key:`s699le`}],[`path`,{d:`m19 12-7 7-7-7`,key:`1idqje`}]]);export{t};

+ 0 - 1
static/assets/arrow-left-MSgMxOFV.js

@@ -1 +0,0 @@
-import{t as e}from"./createLucideIcon-Yo2YoYjm.js";var t=e(`arrow-left`,[[`path`,{d:`m12 19-7-7 7-7`,key:`1l729n`}],[`path`,{d:`M19 12H5`,key:`x3x0zl`}]]);export{t};

+ 0 - 1
static/assets/arrow-right-Da96kh4S.js

@@ -1 +0,0 @@
-import{t as e}from"./createLucideIcon-Yo2YoYjm.js";var t=e(`arrow-right`,[[`path`,{d:`M5 12h14`,key:`1ays0h`}],[`path`,{d:`m12 5 7 7-7 7`,key:`xquz4c`}]]);export{t};

+ 0 - 1
static/assets/arrow-up-Dam8cLfE.js

@@ -1 +0,0 @@
-import{t as e}from"./createLucideIcon-Yo2YoYjm.js";var t=e(`arrow-up`,[[`path`,{d:`m5 12 7-7 7 7`,key:`hav0vg`}],[`path`,{d:`M12 19V5`,key:`x0mq9r`}]]);export{t};

+ 0 - 1
static/assets/arrow-up-down-C-1Kkt1x.js

@@ -1 +0,0 @@
-import{t as e}from"./createLucideIcon-Yo2YoYjm.js";var t=e(`arrow-up-down`,[[`path`,{d:`m21 16-4 4-4-4`,key:`f6ql7i`}],[`path`,{d:`M17 20V4`,key:`1ejh1v`}],[`path`,{d:`m3 8 4-4 4 4`,key:`11wl7u`}],[`path`,{d:`M7 4v16`,key:`1glfcx`}]]);export{t};

+ 0 - 1
static/assets/box-BIRPxdgi.js

@@ -1 +0,0 @@
-import{t as e}from"./createLucideIcon-Yo2YoYjm.js";var t=e(`box`,[[`path`,{d:`M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z`,key:`hh9hay`}],[`path`,{d:`m3.3 7 8.7 5 8.7-5`,key:`g66t2b`}],[`path`,{d:`M12 22V12`,key:`d0xqtd`}]]);export{t};

+ 0 - 1
static/assets/briefcase-BmuFBTLZ.js

@@ -1 +0,0 @@
-import{t as e}from"./createLucideIcon-Yo2YoYjm.js";var t=e(`briefcase`,[[`path`,{d:`M16 20V4a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16`,key:`jecpp`}],[`rect`,{width:`20`,height:`14`,x:`2`,y:`6`,rx:`2`,key:`i6l2r4`}]]);export{t};

+ 0 - 1
static/assets/calendar-DiVEdzUw.js

@@ -1 +0,0 @@
-import{t as e}from"./createLucideIcon-Yo2YoYjm.js";var t=e(`calendar`,[[`path`,{d:`M8 2v4`,key:`1cmpym`}],[`path`,{d:`M16 2v4`,key:`4m81vk`}],[`rect`,{width:`18`,height:`18`,x:`3`,y:`4`,rx:`2`,key:`1hopcy`}],[`path`,{d:`M3 10h18`,key:`8toen8`}]]);export{t};

+ 0 - 1
static/assets/check-D1Pn8T1J.js

@@ -1 +0,0 @@
-import{t as e}from"./createLucideIcon-Yo2YoYjm.js";var t=e(`check`,[[`path`,{d:`M20 6 9 17l-5-5`,key:`1gmf2c`}]]);export{t};

+ 0 - 1
static/assets/chevrons-right-OnQ-9jKz.js

@@ -1 +0,0 @@
-import{t as e}from"./createLucideIcon-Yo2YoYjm.js";var t=e(`chevrons-left`,[[`path`,{d:`m11 17-5-5 5-5`,key:`13zhaf`}],[`path`,{d:`m18 17-5-5 5-5`,key:`h8a8et`}]]),n=e(`chevrons-right`,[[`path`,{d:`m6 17 5-5-5-5`,key:`xnjwq`}],[`path`,{d:`m13 17 5-5-5-5`,key:`17xmmf`}]]);export{t as n,n as t};

+ 0 - 1
static/assets/chunk-aKtaBQYM.js

@@ -1 +0,0 @@
-var e=Object.create,t=Object.defineProperty,n=Object.getOwnPropertyDescriptor,r=Object.getOwnPropertyNames,i=Object.getPrototypeOf,a=Object.prototype.hasOwnProperty,o=(e,t)=>()=>(t||(e((t={exports:{}}).exports,t),e=null),t.exports),s=(e,n)=>{let r={};for(var i in e)t(r,i,{get:e[i],enumerable:!0});return n||t(r,Symbol.toStringTag,{value:`Module`}),r},c=(e,i,o,s)=>{if(i&&typeof i==`object`||typeof i==`function`)for(var c=r(i),l=0,u=c.length,d;l<u;l++)d=c[l],!a.call(e,d)&&d!==o&&t(e,d,{get:(e=>i[e]).bind(null,d),enumerable:!(s=n(i,d))||s.enumerable});return e},l=(n,r,a)=>(a=n==null?{}:e(i(n)),c(r||!n||!n.__esModule?t(a,`default`,{value:n,enumerable:!0}):a,n)),u=(e=>typeof require<`u`?require:typeof Proxy<`u`?new Proxy(e,{get:(e,t)=>(typeof require<`u`?require:e)[t]}):e)(function(e){if(typeof require<`u`)return require.apply(this,arguments);throw Error('Calling `require` for "'+e+"\" in an environment that doesn't expose the `require` function. See https://rolldown.rs/in-depth/bundling-cjs#require-external-modules for more details.")});export{l as i,s as n,u as r,o as t};

File diff suppressed because it is too large
+ 0 - 0
static/assets/client-BdOyoZJz.js


+ 0 - 1
static/assets/cloud-off-DmnG-KIw.js

@@ -1 +0,0 @@
-import{t as e}from"./createLucideIcon-Yo2YoYjm.js";var t=e(`cloud-off`,[[`path`,{d:`m2 2 20 20`,key:`1ooewy`}],[`path`,{d:`M5.782 5.782A7 7 0 0 0 9 19h8.5a4.5 4.5 0 0 0 1.307-.193`,key:`yfwify`}],[`path`,{d:`M21.532 16.5A4.5 4.5 0 0 0 17.5 10h-1.79A7.008 7.008 0 0 0 10 5.07`,key:`jlfiyv`}]]);export{t};

+ 0 - 1
static/assets/cog-pa-nvQzt.js

@@ -1 +0,0 @@
-import{t as e}from"./createLucideIcon-Yo2YoYjm.js";var t=e(`cog`,[[`path`,{d:`M11 10.27 7 3.34`,key:`16pf9h`}],[`path`,{d:`m11 13.73-4 6.93`,key:`794ttg`}],[`path`,{d:`M12 22v-2`,key:`1osdcq`}],[`path`,{d:`M12 2v2`,key:`tus03m`}],[`path`,{d:`M14 12h8`,key:`4f43i9`}],[`path`,{d:`m17 20.66-1-1.73`,key:`eq3orb`}],[`path`,{d:`m17 3.34-1 1.73`,key:`2wel8s`}],[`path`,{d:`M2 12h2`,key:`1t8f8n`}],[`path`,{d:`m20.66 17-1.73-1`,key:`sg0v6f`}],[`path`,{d:`m20.66 7-1.73 1`,key:`1ow05n`}],[`path`,{d:`m3.34 17 1.73-1`,key:`nuk764`}],[`path`,{d:`m3.34 7 1.73 1`,key:`1ulond`}],[`circle`,{cx:`12`,cy:`12`,r:`2`,key:`1c9p78`}],[`circle`,{cx:`12`,cy:`12`,r:`8`,key:`46899m`}]]);export{t};

Some files were not shown because too many files changed in this diff