Kaynağa Gözat

feat(obico): authenticate to a token-protected ML API (#2733)

Obico's ml_api container takes an optional ML_API_TOKEN environment variable.
With it set, ml_api/auth.py answers a bare 401 to any request whose
Authorization header isn't "Bearer <token>"; with it unset it ignores the
header entirely. Bambuddy never sent one, so pointing it at a protected server
meant deleting the token there — which the reporter had set for their Home
Assistant integration and did not want to undo.

Settings -> Failure Detection gains an ML API Token field. When it is empty no
header is sent, so an unconfigured install's request stays byte-identical to
what shipped before the setting existed.

This failed in the worst possible way, and that is the more important half of
the change. Obico decorates /p/ with token_required but leaves /hc/ open. Test
Connection pinged /hc/, so it reported success against a server that was
rejecting every real detection call, the settings looked right, and detection
silently never ran. The only symptom was a generic "ML API call failed" buried
in the status card.

So the test now proves what it claims. After health passes it probes GET /p/
with no img parameter: the auth decorator runs before the handler, so 401 means
the token was rejected and 422 ("Invalid request params") means it was
accepted. No inference work is done either way. A probe that itself errors
reports the token as unknown rather than as working — the UI says it could not
be checked instead of claiming success.

The detection loop checks for 401 before raise_for_status, so a rejected token
is reported as a rejected token, naming the setting and the environment
variable, instead of surfacing "401 Unauthorized" with no hint of what to do.
The message never contains the token; a test pins that.

The setting name carries "token", so the support bundle's keyword redactor
masks it with no new rule. Resolving "field omitted" to the saved token is the
route's job, keeping test_connection a pure outbound call with no database
access.

Second fix, same issue: support bundles misreported which printers Obico
watches. The bundle split obico_enabled_printers on commas and read an empty
value as "no printers". The settings UI writes a JSON array, and empty means
*all* printers — the default — so a working Obico setup showed obico_enabled
false against every printer in its own bundle. That is the reporter's bundle
exactly, and it points anyone reading it at the wrong subsystem. The bundle now
parses the setting the way ObicoDetectionService does, keeps a comma fallback
for any install that stored the legacy shape, and factors in the global switch.
maziggy 1 ay önce
ebeveyn
işleme
11dc612bc4

Dosya farkı çok büyük olduğundan ihmal edildi
+ 4 - 0
CHANGELOG.md


+ 10 - 3
backend/app/api/routes/obico.py

@@ -17,6 +17,8 @@ router = APIRouter(prefix="/obico", tags=["obico"])
 
 class TestConnectionRequest(BaseModel):
     url: str
+    # Omitted entirely = test with the saved token; "" = test with no token.
+    token: str | None = None
 
 
 @router.get("/status")
@@ -65,10 +67,15 @@ async def test_connection(
     req: TestConnectionRequest,
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
 ):
-    """Ping the Obico ML API `/hc/` health endpoint. Returns ok + raw body."""
+    """Ping the Obico ML API health endpoint and check the token. Returns ok + raw body."""
     if not req.url:
-        return {"ok": False, "status_code": None, "body": None, "error": "URL is empty"}
-    return await obico_detection_service.test_connection(req.url)
+        return {"ok": False, "status_code": None, "body": None, "error": "URL is empty", "auth_ok": None}
+    token = req.token
+    if token is None:
+        # Field omitted entirely — test what the service actually uses.
+        settings = await obico_detection_service._load_settings()
+        token = settings.get("ml_token") or ""
+    return await obico_detection_service.test_connection(req.url, token)
 
 
 @router.get("/cached-frame/{nonce}")

+ 39 - 20
backend/app/api/routes/support.py

@@ -647,20 +647,29 @@ async def _collect_slicer_api_info() -> dict:
     return info
 
 
-def _parse_obico_enabled_printers(raw: str) -> set[int]:
-    """Parse the comma-separated `obico_enabled_printers` setting. Same shape as
-    obico_detection.py uses but tolerant of legacy formats."""
+def _parse_obico_enabled_printers(raw: str | None) -> set[int] | None:
+    """Parse the `obico_enabled_printers` setting the way the detection service does.
+
+    The setting is a JSON array of printer IDs and an empty value means *all*
+    printers — see ``ObicoDetectionService._load_settings``. This used to split
+    on commas and treat empty as *none*, so a bundle from a default Obico setup
+    reported every printer as unmonitored while the service was in fact polling
+    all of them. Returns ``None`` for "all printers"; a comma-separated fallback
+    is kept in case an install ever stored the legacy shape.
+    """
     if not raw or not raw.strip():
-        return set()
+        return None
+    try:
+        parsed = json.loads(raw)
+    except (json.JSONDecodeError, TypeError):
+        parsed = None
+    if isinstance(parsed, list):
+        return {int(item) for item in parsed if isinstance(item, (int, str)) and str(item).strip().isdigit()}
     result: set[int] = set()
     for token in raw.split(","):
         token = token.strip()
-        if not token:
-            continue
-        try:
+        if token.isdigit():
             result.add(int(token))
-        except ValueError:
-            continue
     return result
 
 
@@ -729,18 +738,27 @@ async def _collect_support_info() -> dict:
         printers = result.scalars().all()
         statuses = printer_manager.get_all_statuses()
 
-        # Pre-load the obico per-printer enabled-list. Settings are loaded later
-        # in this function (and would overwrite this key in info["settings"]),
-        # so do a targeted query here for the per-printer flag below.
-        obico_enabled_set: set[int] = set()
+        # Pre-load the obico settings that decide which printers are monitored.
+        # Settings are loaded later in this function (and would overwrite these
+        # keys in info["settings"]), so do a targeted query here for the
+        # per-printer flag below. ``None`` means every printer is monitored.
+        obico_enabled_set: set[int] | None = None
+        obico_globally_enabled = False
         try:
-            obico_row = (
-                await db.execute(select(Settings).where(Settings.key == "obico_enabled_printers"))
-            ).scalar_one_or_none()
-            if obico_row is not None:
-                obico_enabled_set = _parse_obico_enabled_printers(obico_row.value)
+            obico_rows = {
+                row.key: row.value
+                for row in (
+                    await db.execute(
+                        select(Settings).where(Settings.key.in_(["obico_enabled_printers", "obico_enabled"]))
+                    )
+                )
+                .scalars()
+                .all()
+            }
+            obico_enabled_set = _parse_obico_enabled_printers(obico_rows.get("obico_enabled_printers"))
+            obico_globally_enabled = (obico_rows.get("obico_enabled") or "false").lower() == "true"
         except Exception:
-            logger.debug("Failed to load obico_enabled_printers", exc_info=True)
+            logger.debug("Failed to load obico settings", exc_info=True)
 
         # Check reachability in parallel
         reachability_tasks = [_check_port(p.ip_address, 8883) for p in printers]
@@ -784,7 +802,8 @@ async def _collect_support_info() -> dict:
                     "has_vt_tray": has_vt_tray,
                     "external_camera_configured": bool(printer.external_camera_url),
                     "plate_detection_enabled": printer.plate_detection_enabled,
-                    "obico_enabled": printer.id in obico_enabled_set,
+                    "obico_enabled": obico_globally_enabled
+                    and (obico_enabled_set is None or printer.id in obico_enabled_set),
                     "hms_error_count": len(state.hms_errors) if state else 0,
                     "developer_mode": state.developer_mode if state else None,
                     "nozzle_rack_count": len(state.nozzle_rack) if state else 0,

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

@@ -467,6 +467,13 @@ class AppSettings(BaseModel):
         default="",
         description="Self-hosted Obico ML API base URL (e.g., http://192.168.1.10:3333)",
     )
+    obico_ml_token: str = Field(
+        default="",
+        description=(
+            "Bearer token for the Obico ML API, matching the server's ML_API_TOKEN "
+            "environment variable. Empty when the server runs without one."
+        ),
+    )
     obico_sensitivity: str = Field(
         default="medium",
         description="Detection sensitivity: 'low', 'medium', or 'high' (adjusts LOW/HIGH thresholds)",
@@ -618,6 +625,7 @@ class AppSettingsUpdate(BaseModel):
     ldap_default_group: str | None = None
     obico_enabled: bool | None = None
     obico_ml_url: str | None = None
+    obico_ml_token: str | None = None
     obico_sensitivity: str | None = None
     obico_action: str | None = None
     obico_poll_interval: int | None = Field(default=None, ge=5, le=120)

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

@@ -44,6 +44,19 @@ _frame_cache: dict[str, tuple[bytes, float]] = {}
 _frame_cache_lock = asyncio.Lock()
 
 
+def auth_headers(token: str | None) -> dict[str, str]:
+    """Bearer header for the ML API, or nothing when no token is configured.
+
+    Obico's ML API gates ``/p/`` behind ``ML_API_TOKEN`` (``ml_api/auth.py``):
+    with the variable set it answers a bare 401 to any request whose
+    ``Authorization`` header isn't ``Bearer <token>``, and with it unset it
+    ignores the header entirely. Sending nothing when unconfigured keeps the
+    request byte-identical to what shipped before the setting existed.
+    """
+    token = (token or "").strip()
+    return {"Authorization": f"Bearer {token}"} if token else {}
+
+
 def _prune_frame_cache() -> None:
     """Drop entries older than FRAME_CACHE_TTL. Called under the cache lock."""
     now = time.monotonic()
@@ -111,6 +124,7 @@ class ObicoDetectionService:
         keys = [
             "obico_enabled",
             "obico_ml_url",
+            "obico_ml_token",
             "obico_sensitivity",
             "obico_action",
             "obico_poll_interval",
@@ -133,6 +147,7 @@ class ObicoDetectionService:
         return {
             "enabled": rows.get("obico_enabled", "false").lower() == "true",
             "ml_url": (rows.get("obico_ml_url") or "").rstrip("/"),
+            "ml_token": (rows.get("obico_ml_token") or "").strip(),
             "sensitivity": rows.get("obico_sensitivity", "medium"),
             "action": rows.get("obico_action", "notify"),
             "poll_interval": int(rows.get("obico_poll_interval", "10")),
@@ -279,7 +294,23 @@ class ObicoDetectionService:
 
         try:
             async with httpx.AsyncClient(timeout=DETECTION_TIMEOUT) as client:
-                resp = await client.get(ml_url, params={"img": snapshot_url})
+                resp = await client.get(
+                    ml_url,
+                    params={"img": snapshot_url},
+                    headers=auth_headers(settings.get("ml_token")),
+                )
+                if resp.status_code == 401:
+                    # The server runs with ML_API_TOKEN set and rejected ours.
+                    # Say so plainly: the health endpoint is ungated, so "Test
+                    # Connection" passes against exactly this configuration and
+                    # a raw 401 gives the user nothing to act on (#2733).
+                    self._last_error = (
+                        "Obico ML API rejected the token (401). Set Settings → Failure Detection → "
+                        "ML API Token to the ML_API_TOKEN the server runs with, or clear ML_API_TOKEN "
+                        "on the server."
+                    )
+                    logger.warning("%s (printer %s)", self._last_error, printer_id)
+                    return
                 resp.raise_for_status()
                 payload = resp.json()
         except Exception as e:
@@ -364,8 +395,8 @@ class ObicoDetectionService:
             "history": list(self._history),
         }
 
-    async def test_connection(self, url: str) -> dict:
-        """Ping the ML API health endpoint. Returns {ok, status_code, body, error}.
+    async def test_connection(self, url: str, token: str = "") -> dict:
+        """Ping the ML API and check the token. Returns {ok, status_code, body, error, auth_ok}.
 
         The stored ``obico_ml_url`` setting is validated at the schema layer,
         but this route takes its URL from the request body, so the same
@@ -374,27 +405,71 @@ class ObicoDetectionService:
         is returned to the caller (it is the health signal — the endpoint
         answers "ok"), which is exactly why the destination must be inside
         policy before the request is made.
+
+        ``token`` is used verbatim — resolving "not supplied" to the saved
+        setting is the route's job, so this stays a pure outbound call.
+
+        Health alone cannot answer whether the token works, because Obico
+        gates ``/p/`` but leaves ``/hc/`` open — which is how a token-protected
+        server passed this test while every detection call came back 401
+        (#2733). So a second, side-effect-free probe follows: ``/p/`` with no
+        ``img`` parameter. The auth decorator runs before the handler, so 401
+        means the token was rejected and 422 ("Invalid request params") means
+        it was accepted. No inference work is done either way.
         """
         from backend.app.api.routes._url_safety import assert_safe_lan_service_url
 
         try:
             assert_safe_lan_service_url(url, label="Obico ML URL")
         except ValueError as exc:
-            return {"ok": False, "status_code": None, "body": None, "error": str(exc)}
+            return {"ok": False, "status_code": None, "body": None, "error": str(exc), "auth_ok": None}
 
-        target = f"{url.rstrip('/')}/hc/"
+        headers = auth_headers(token)
+
+        base = url.rstrip("/")
         try:
             async with httpx.AsyncClient(timeout=HEALTH_TIMEOUT) as client:
-                resp = await client.get(target)
-            body = resp.text.strip()
+                resp = await client.get(f"{base}/hc/", headers=headers)
+                body = resp.text.strip()
+                healthy = resp.status_code == 200 and body.lower() == "ok"
+                if not healthy:
+                    return {
+                        "ok": False,
+                        "status_code": resp.status_code,
+                        "body": body,
+                        "error": None,
+                        "auth_ok": None,
+                    }
+
+                auth_ok: bool | None
+                try:
+                    probe = await client.get(f"{base}/p/", headers=headers)
+                    auth_ok = probe.status_code != 401
+                except Exception:
+                    # The health check already succeeded, so don't fail the
+                    # whole test on the probe — report the token as unknown.
+                    auth_ok = None
+        except Exception as e:
+            return {
+                "ok": False,
+                "status_code": None,
+                "body": None,
+                "error": str(e) or type(e).__name__,
+                "auth_ok": None,
+            }
+
+        if auth_ok is False:
             return {
-                "ok": resp.status_code == 200 and body.lower() == "ok",
-                "status_code": resp.status_code,
+                "ok": False,
+                "status_code": 401,
                 "body": body,
-                "error": None,
+                "error": (
+                    "The ML API is reachable but rejected the token. It runs with ML_API_TOKEN set — "
+                    "enter that value as the ML API Token, or clear ML_API_TOKEN on the server."
+                ),
+                "auth_ok": False,
             }
-        except Exception as e:
-            return {"ok": False, "status_code": None, "body": None, "error": str(e) or type(e).__name__}
+        return {"ok": True, "status_code": resp.status_code, "body": body, "error": None, "auth_ok": auth_ok}
 
 
 obico_detection_service = ObicoDetectionService()

+ 199 - 0
backend/tests/unit/test_obico_detection.py

@@ -132,6 +132,205 @@ class TestTestConnection:
         assert result["body"] == "something else"
 
 
+class TestMlApiToken:
+    """Obico's ML API gates /p/ behind ML_API_TOKEN (#2733)."""
+
+    def test_auth_headers_only_when_configured(self):
+        from backend.app.services.obico_detection import auth_headers
+
+        assert auth_headers("s3cret") == {"Authorization": "Bearer s3cret"}
+        # Unconfigured must stay byte-identical to the pre-setting request.
+        assert auth_headers("") == {}
+        assert auth_headers(None) == {}
+        assert auth_headers("   ") == {}
+        # Whitespace around a real token is a paste artefact, not part of it.
+        assert auth_headers("  s3cret  ") == {"Authorization": "Bearer s3cret"}
+
+    def test_settings_schema_accepts_a_token(self):
+        assert AppSettingsUpdate(obico_ml_token="s3cret").obico_ml_token == "s3cret"
+        assert AppSettingsUpdate(obico_ml_token="").obico_ml_token == ""
+        assert AppSettingsUpdate().obico_ml_token is None
+
+    @staticmethod
+    def _settings(**overrides):
+        base = {
+            "enabled": True,
+            "ml_url": "http://obico:3333",
+            "ml_token": "",
+            "sensitivity": "medium",
+            "action": "notify",
+            "poll_interval": 10,
+            "enabled_printers": None,
+            "external_url": "http://bambuddy:8000",
+        }
+        base.update(overrides)
+        return base
+
+    @staticmethod
+    def _client(response):
+        mock_client = MagicMock()
+        mock_client.get = AsyncMock(return_value=response)
+        mock_client.__aenter__ = AsyncMock(return_value=mock_client)
+        mock_client.__aexit__ = AsyncMock(return_value=False)
+        return mock_client
+
+    @pytest.mark.asyncio
+    async def test_detection_call_carries_the_bearer_header(self):
+        svc = ObicoDetectionService()
+        response = MagicMock(status_code=200)
+        response.json.return_value = {"detections": []}
+        response.raise_for_status = MagicMock()
+        mock_client = self._client(response)
+        status = MagicMock(state="RUNNING", task_name="job", subtask_name="")
+
+        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, status, self._settings(ml_token="s3cret"))
+
+        assert mock_client.get.await_args.kwargs["headers"] == {"Authorization": "Bearer s3cret"}
+
+    @pytest.mark.asyncio
+    async def test_detection_call_sends_no_header_without_a_token(self):
+        svc = ObicoDetectionService()
+        response = MagicMock(status_code=200)
+        response.json.return_value = {"detections": []}
+        response.raise_for_status = MagicMock()
+        mock_client = self._client(response)
+        status = MagicMock(state="RUNNING", task_name="job", subtask_name="")
+
+        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, status, self._settings())
+
+        assert mock_client.get.await_args.kwargs["headers"] == {}
+
+    @pytest.mark.asyncio
+    async def test_401_reports_the_token_rather_than_a_bare_http_error(self):
+        svc = ObicoDetectionService()
+        response = MagicMock(status_code=401)
+        # raise_for_status would also raise here; the status check must come first
+        # so the user gets an actionable message instead of "401 Unauthorized".
+        response.raise_for_status = MagicMock(side_effect=AssertionError("must not reach raise_for_status"))
+        mock_client = self._client(response)
+        status = MagicMock(state="RUNNING", task_name="job", subtask_name="")
+
+        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, status, self._settings(ml_token="wrong"))
+
+        assert "401" in svc._last_error
+        assert "ML_API_TOKEN" in svc._last_error
+        # A rejected call must not be scored as a clean frame.
+        assert 1 not in svc._states or svc._states[1].frame_count == 0
+
+    @pytest.mark.asyncio
+    async def test_401_message_does_not_leak_the_token(self):
+        svc = ObicoDetectionService()
+        response = MagicMock(status_code=401)
+        response.raise_for_status = MagicMock()
+        mock_client = self._client(response)
+        status = MagicMock(state="RUNNING", task_name="job", subtask_name="")
+
+        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, status, self._settings(ml_token="sup3rs3cret"))
+
+        assert "sup3rs3cret" not in svc._last_error
+
+
+class TestTestConnectionTokenProbe:
+    """/hc/ is ungated, so health alone cannot validate the token (#2733)."""
+
+    @staticmethod
+    def _client(responses):
+        mock_client = MagicMock()
+        mock_client.get = AsyncMock(side_effect=responses)
+        mock_client.__aenter__ = AsyncMock(return_value=mock_client)
+        mock_client.__aexit__ = AsyncMock(return_value=False)
+        return mock_client
+
+    @pytest.mark.asyncio
+    async def test_healthy_but_rejected_token_is_not_ok(self):
+        svc = ObicoDetectionService()
+        mock_client = self._client([MagicMock(status_code=200, text="ok"), MagicMock(status_code=401)])
+
+        with patch("backend.app.services.obico_detection.httpx.AsyncClient", return_value=mock_client):
+            result = await svc.test_connection("http://obico:3333", "wrong")
+
+        assert result["ok"] is False
+        assert result["auth_ok"] is False
+        assert result["status_code"] == 401
+        assert "ML_API_TOKEN" in result["error"]
+
+    @pytest.mark.asyncio
+    async def test_accepted_token_is_ok(self):
+        svc = ObicoDetectionService()
+        # 422 = "Invalid request params": auth passed, then the handler rejected
+        # the img-less probe. That is the success signal.
+        mock_client = self._client([MagicMock(status_code=200, text="ok"), MagicMock(status_code=422)])
+
+        with patch("backend.app.services.obico_detection.httpx.AsyncClient", return_value=mock_client):
+            result = await svc.test_connection("http://obico:3333", "right")
+
+        assert result["ok"] is True
+        assert result["auth_ok"] is True
+        assert result["error"] is None
+
+    @pytest.mark.asyncio
+    async def test_probe_failure_leaves_the_token_unknown_but_keeps_the_test_ok(self):
+        svc = ObicoDetectionService()
+        mock_client = self._client([MagicMock(status_code=200, text="ok"), RuntimeError("read timeout")])
+
+        with patch("backend.app.services.obico_detection.httpx.AsyncClient", return_value=mock_client):
+            result = await svc.test_connection("http://obico:3333", "maybe")
+
+        assert result["ok"] is True
+        assert result["auth_ok"] is None
+
+    @pytest.mark.asyncio
+    async def test_unhealthy_server_is_not_probed(self):
+        svc = ObicoDetectionService()
+        mock_client = self._client([MagicMock(status_code=200, text="error")])
+
+        with patch("backend.app.services.obico_detection.httpx.AsyncClient", return_value=mock_client):
+            result = await svc.test_connection("http://obico:3333", "any")
+
+        assert result["ok"] is False
+        assert result["auth_ok"] is None
+        assert mock_client.get.await_count == 1
+
+    @pytest.mark.asyncio
+    async def test_both_requests_carry_the_header(self):
+        svc = ObicoDetectionService()
+        mock_client = self._client([MagicMock(status_code=200, text="ok"), MagicMock(status_code=422)])
+
+        with patch("backend.app.services.obico_detection.httpx.AsyncClient", return_value=mock_client):
+            await svc.test_connection("http://obico:3333", "s3cret")
+
+        assert [call.args[0] for call in mock_client.get.await_args_list] == [
+            "http://obico:3333/hc/",
+            "http://obico:3333/p/",
+        ]
+        for call in mock_client.get.await_args_list:
+            assert call.kwargs["headers"] == {"Authorization": "Bearer s3cret"}
+
+    @pytest.mark.asyncio
+    async def test_url_policy_still_applies_before_any_request(self):
+        svc = ObicoDetectionService()
+        result = await svc.test_connection("http://169.254.169.254/latest/meta-data/", "s3cret")
+        assert result["ok"] is False
+        assert result["auth_ok"] is None
+        assert result["error"]
+
+
 class TestPollOneStateLifecycle:
     """Confirms per-printer state is reset when a new print starts."""
 

+ 27 - 6
backend/tests/unit/test_support_helpers.py

@@ -701,19 +701,33 @@ class TestCollectSupportInfo:
 
 
 class TestParseObicoEnabledPrinters:
-    """Tests for the per-printer obico flag parser used by the bundle."""
+    """Tests for the per-printer obico flag parser used by the bundle.
 
-    def test_empty_string_returns_empty_set(self):
+    The setting is written by the settings UI as a JSON array and read by
+    ObicoDetectionService._load_settings as one; the bundle used to split it on
+    commas and call empty "no printers", so a default Obico setup was reported
+    as monitoring nothing while it was in fact monitoring everything (#2733).
+    """
+
+    def test_empty_means_all_printers(self):
+        from backend.app.api.routes.support import _parse_obico_enabled_printers
+
+        # None (not "no printers") — the same convention _load_settings uses.
+        assert _parse_obico_enabled_printers("") is None
+        assert _parse_obico_enabled_printers("   ") is None
+        assert _parse_obico_enabled_printers(None) is None
+
+    def test_json_array_is_the_stored_shape(self):
         from backend.app.api.routes.support import _parse_obico_enabled_printers
 
-        assert _parse_obico_enabled_printers("") == set()
-        assert _parse_obico_enabled_printers("   ") == set()
+        assert _parse_obico_enabled_printers("[1, 2, 3]") == {1, 2, 3}
+        assert _parse_obico_enabled_printers("[]") == set()
 
-    def test_comma_separated_ids(self):
+    def test_comma_separated_ids_still_parse(self):
+        # Legacy fallback for any install that stored the old shape.
         from backend.app.api.routes.support import _parse_obico_enabled_printers
 
         assert _parse_obico_enabled_printers("1,2,3") == {1, 2, 3}
-        # Whitespace around tokens is forgiven (matches obico_detection's parser).
         assert _parse_obico_enabled_printers("1, 2 ,3") == {1, 2, 3}
 
     def test_non_integer_tokens_are_skipped(self):
@@ -722,6 +736,13 @@ class TestParseObicoEnabledPrinters:
 
         assert _parse_obico_enabled_printers("1,abc,2") == {1, 2}
         assert _parse_obico_enabled_printers(",,1,") == {1}
+        assert _parse_obico_enabled_printers('[1, "two", 3]') == {1, 3}
+
+    def test_json_object_is_not_a_printer_list(self):
+        from backend.app.api.routes.support import _parse_obico_enabled_printers
+
+        # Falls through to the comma parser, which finds no integers.
+        assert _parse_obico_enabled_printers('{"1": true}') == set()
 
 
 class TestCheckUrlReachable:

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

@@ -23,6 +23,7 @@ const baseSettings = {
   include_beta_updates: false,
   obico_enabled: false,
   obico_ml_url: '',
+  obico_ml_token: '',
   obico_sensitivity: 'medium',
   obico_action: 'notify',
   obico_poll_interval: 10,
@@ -83,6 +84,102 @@ describe('FailureDetectionSettings', () => {
     expect(await screen.findByText(/ML API reachable/i)).toBeInTheDocument();
   });
 
+  describe('ML API token (#2733)', () => {
+    const enabledWithToken = {
+      ...baseSettings,
+      obico_enabled: true,
+      obico_ml_url: 'http://obico:3333',
+      obico_ml_token: 's3cret',
+    };
+
+    it('renders the token as a masked field populated from settings', async () => {
+      server.use(http.get('/api/v1/settings/', () => HttpResponse.json(enabledWithToken)));
+      render(<FailureDetectionSettings />);
+
+      const input = await screen.findByDisplayValue('s3cret');
+      expect(input).toHaveAttribute('type', 'password');
+      expect(screen.getByText(/ML API Token/i)).toBeInTheDocument();
+    });
+
+    it('sends the token with the test-connection request', async () => {
+      let sent: { url: string; token?: string } | null = null;
+      server.use(
+        http.get('/api/v1/settings/', () => HttpResponse.json(enabledWithToken)),
+        http.post('/api/v1/obico/test-connection', async ({ request }) => {
+          sent = (await request.json()) as { url: string; token?: string };
+          return HttpResponse.json({
+            ok: true,
+            status_code: 200,
+            body: 'ok',
+            error: null,
+            auth_ok: true,
+          });
+        }),
+      );
+      render(<FailureDetectionSettings />);
+      await screen.findByDisplayValue('http://obico:3333');
+      await userEvent.click(screen.getByRole('button', { name: /test/i }));
+
+      await waitFor(() => expect(sent).not.toBeNull());
+      // The value in the box, not the saved one — so a token can be checked
+      // before it is committed.
+      expect(sent!.token).toBe('s3cret');
+    });
+
+    it('reports a rejected token instead of a bare success', async () => {
+      server.use(
+        http.get('/api/v1/settings/', () => HttpResponse.json(enabledWithToken)),
+        http.post('/api/v1/obico/test-connection', () =>
+          HttpResponse.json({
+            ok: false,
+            status_code: 401,
+            body: 'ok',
+            error: 'The ML API is reachable but rejected the token.',
+            auth_ok: false,
+          }),
+        ),
+      );
+      render(<FailureDetectionSettings />);
+      await screen.findByDisplayValue('http://obico:3333');
+      await userEvent.click(screen.getByRole('button', { name: /test/i }));
+
+      expect(await screen.findByText(/rejected the token/i)).toBeInTheDocument();
+    });
+
+    it('does not claim the token works when it could not be checked', async () => {
+      server.use(
+        http.get('/api/v1/settings/', () => HttpResponse.json(enabledWithToken)),
+        http.post('/api/v1/obico/test-connection', () =>
+          HttpResponse.json({ ok: true, status_code: 200, body: 'ok', error: null, auth_ok: null }),
+        ),
+      );
+      render(<FailureDetectionSettings />);
+      await screen.findByDisplayValue('http://obico:3333');
+      await userEvent.click(screen.getByRole('button', { name: /test/i }));
+
+      expect(await screen.findByText(/token could not be checked/i)).toBeInTheDocument();
+    });
+
+    it('auto-saves the token', async () => {
+      let saved: Record<string, unknown> | null = null;
+      server.use(
+        http.get('/api/v1/settings/', () => HttpResponse.json({ ...enabledWithToken, obico_ml_token: '' })),
+        http.put('/api/v1/settings/', async ({ request }) => {
+          saved = (await request.json()) as Record<string, unknown>;
+          return HttpResponse.json({ ...enabledWithToken, obico_ml_token: 'typed' });
+        }),
+      );
+      render(<FailureDetectionSettings />);
+      const input = await screen.findByPlaceholderText(/ML_API_TOKEN/i);
+      // Every field stays disabled until the settings query lands.
+      await waitFor(() => expect(input).not.toBeDisabled());
+      await userEvent.type(input, 'typed');
+
+      await waitFor(() => expect(saved).not.toBeNull(), { timeout: 3000 });
+      expect(saved!.obico_ml_token).toBe('typed');
+    });
+  });
+
   it('shows failure class history entries with red styling', async () => {
     server.use(
       http.get('/api/v1/obico/status', () =>

+ 8 - 2
frontend/src/api/client.ts

@@ -1337,6 +1337,7 @@ export interface AppSettings {
   ldap_default_group: string;
   obico_enabled: boolean;
   obico_ml_url: string;
+  obico_ml_token: string;
   obico_sensitivity: 'low' | 'medium' | 'high';
   obico_action: 'notify' | 'pause' | 'pause_and_off';
   obico_poll_interval: number;
@@ -2780,6 +2781,9 @@ export interface ObicoTestConnection {
   status_code: number | null;
   body: string | null;
   error: string | null;
+  // Whether the ML API accepted the token. null = not determined (the health
+  // check failed first, or the token probe itself errored).
+  auth_ok: boolean | null;
 }
 
 export interface GitHubTestConnectionResponse {
@@ -6523,10 +6527,12 @@ export const api = {
   getObicoPrinterStatus: () =>
     request<ObicoPrinterStatus>('/obico/printer-status'),
 
-  testObicoConnection: (url: string) =>
+  // `token` is sent as-is, so an empty string tests with no token at all.
+  // Omitting the argument makes the backend fall back to the saved token.
+  testObicoConnection: (url: string, token?: string) =>
     request<ObicoTestConnection>('/obico/test-connection', {
       method: 'POST',
-      body: JSON.stringify({ url }),
+      body: JSON.stringify(token === undefined ? { url } : { url, token }),
     }),
 
   // Slicer API — slice in the background. Both endpoints return 202 + a

+ 29 - 3
frontend/src/components/FailureDetectionSettings.tsx

@@ -17,6 +17,7 @@ export function FailureDetectionSettings() {
 
   const [enabled, setEnabled] = useState(false);
   const [mlUrl, setMlUrl] = useState('');
+  const [mlToken, setMlToken] = useState('');
   const [sensitivity, setSensitivity] = useState<'low' | 'medium' | 'high'>('medium');
   const [action, setAction] = useState<'notify' | 'pause' | 'pause_and_off'>('notify');
   const [pollInterval, setPollInterval] = useState(10);
@@ -44,6 +45,7 @@ export function FailureDetectionSettings() {
     if (!settings) return;
     setEnabled(settings.obico_enabled ?? false);
     setMlUrl(settings.obico_ml_url ?? '');
+    setMlToken(settings.obico_ml_token ?? '');
     setSensitivity(settings.obico_sensitivity ?? 'medium');
     setAction(settings.obico_action ?? 'notify');
     setPollInterval(settings.obico_poll_interval ?? 10);
@@ -63,6 +65,7 @@ export function FailureDetectionSettings() {
       api.updateSettings({
         obico_enabled: enabled,
         obico_ml_url: mlUrl,
+        obico_ml_token: mlToken,
         obico_sensitivity: sensitivity,
         obico_action: action,
         obico_poll_interval: pollInterval,
@@ -84,6 +87,7 @@ export function FailureDetectionSettings() {
     const changed =
       settings.obico_enabled !== enabled ||
       settings.obico_ml_url !== mlUrl ||
+      (settings.obico_ml_token ?? '') !== mlToken ||
       settings.obico_sensitivity !== sensitivity ||
       settings.obico_action !== action ||
       settings.obico_poll_interval !== pollInterval ||
@@ -92,14 +96,23 @@ export function FailureDetectionSettings() {
     const id = setTimeout(() => saveMutation.mutate(), 500);
     return () => clearTimeout(id);
     // eslint-disable-next-line react-hooks/exhaustive-deps
-  }, [enabled, mlUrl, sensitivity, action, pollInterval, enabledPrinters, initialized]);
+  }, [enabled, mlUrl, mlToken, sensitivity, action, pollInterval, enabledPrinters, initialized]);
 
   const handleTest = async () => {
     setTestResult(null);
     try {
-      const res = await api.testObicoConnection(mlUrl);
+      const res = await api.testObicoConnection(mlUrl, mlToken);
       if (res.ok) {
-        setTestResult({ ok: true, message: t('failureDetection.testSuccess') });
+        // auth_ok is null when the token could not be checked — don't claim it
+        // works. It is true both for an accepted token and for a server that
+        // requires none, which is the same outcome for the user.
+        setTestResult({
+          ok: true,
+          message:
+            res.auth_ok === null
+              ? t('failureDetection.testSuccessTokenUnknown')
+              : t('failureDetection.testSuccess'),
+        });
       } else {
         setTestResult({
           ok: false,
@@ -163,6 +176,19 @@ export function FailureDetectionSettings() {
                 </Button>
               </div>
               <p className="text-xs text-bambu-gray mt-1">{t('failureDetection.mlUrlHint')}</p>
+              <label className="block text-sm text-bambu-gray mb-1 mt-3">
+                {t('failureDetection.mlToken')}
+              </label>
+              <input
+                type="password"
+                value={mlToken}
+                onChange={(e) => setMlToken(e.target.value)}
+                autoComplete="off"
+                placeholder={t('failureDetection.mlTokenPlaceholder')}
+                className="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-white text-sm"
+                disabled={!enabled}
+              />
+              <p className="text-xs text-bambu-gray mt-1">{t('failureDetection.mlTokenHint')}</p>
               {testResult && (
                 <div
                   className={`flex items-start gap-2 mt-2 text-sm ${

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

@@ -6530,8 +6530,12 @@ 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).',
+    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.',
     test: 'Testen',
     testSuccess: 'ML-API erreichbar und funktionsfähig.',
+    testSuccessTokenUnknown: 'ML-API erreichbar und funktionsfähig. Das Token konnte nicht geprüft werden.',
     testFailed: 'ML-API konnte nicht erreicht werden.',
     sensitivity: 'Empfindlichkeit',
     sensitivityLow: 'Niedrig (weniger Fehlalarme)',

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

@@ -6574,8 +6574,12 @@ 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).',
+    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.',
     test: 'Test',
     testSuccess: 'ML API reachable and healthy.',
+    testSuccessTokenUnknown: 'ML API reachable and healthy. The token could not be checked.',
     testFailed: 'Could not reach the ML API.',
     sensitivity: 'Sensitivity',
     sensitivityLow: 'Low (fewer false positives)',

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

@@ -6539,8 +6539,12 @@ 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).',
+    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.',
     test: 'Probar',
     testSuccess: 'API de ML accesible y correcta.',
+    testSuccessTokenUnknown: 'API de ML accesible y correcta. No se pudo comprobar el token.',
     testFailed: 'No se pudo alcanzar la API de ML.',
     sensitivity: 'Sensibilidad',
     sensitivityLow: 'Baja (menos falsos positivos)',

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

@@ -6520,8 +6520,12 @@ 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).',
+    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.',
     test: 'Tester',
     testSuccess: 'API ML accessible et fonctionnelle.',
+    testSuccessTokenUnknown: 'API ML accessible et fonctionnelle. Le jeton n\'a pas pu être vérifié.',
     testFailed: 'Impossible d\'atteindre l\'API ML.',
     sensitivity: 'Sensibilité',
     sensitivityLow: 'Basse (moins de faux positifs)',

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

@@ -6519,8 +6519,12 @@ 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).',
+    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.',
     test: 'Prova',
     testSuccess: 'API ML raggiungibile e funzionante.',
+    testSuccessTokenUnknown: 'API ML raggiungibile e funzionante. Non è stato possibile verificare il token.',
     testFailed: 'Impossibile raggiungere l\'API ML.',
     sensitivity: 'Sensibilità',
     sensitivityLow: 'Bassa (meno falsi positivi)',

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

@@ -6531,8 +6531,12 @@ export default {
     description: 'セルフホストされた Obico ML API で印刷を監視し、検出された失敗に自動的に対応します。',
     mlUrl: 'Obico ML API の URL',
     mlUrlHint: 'セルフホストした Obico ml_api コンテナのベース URL (例: http://192.168.1.10:3333)。',
+    mlToken: 'ML API トークン(任意)',
+    mlTokenPlaceholder: 'サーバーが ML_API_TOKEN なしで動作している場合は空のままにします',
+    mlTokenHint: 'Obico ml_api コンテナの環境変数 ML_API_TOKEN と一致させる必要があります。コンテナがトークンなしで動作している場合は空のままにしてください。',
     test: 'テスト',
     testSuccess: 'ML API に接続でき、正常です。',
+    testSuccessTokenUnknown: 'ML API に接続でき、正常です。トークンは確認できませんでした。',
     testFailed: 'ML API に接続できませんでした。',
     sensitivity: '感度',
     sensitivityLow: '低(誤検出が少ない)',

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

@@ -5999,8 +5999,12 @@ export default {
     description: '자체 호스팅 Obico ML API로 인쇄를 모니터링하고 감지된 실패에 자동으로 조치합니다.',
     mlUrl: 'Obico ML API URL',
     mlUrlHint: '자체 호스팅 Obico ml_api 컨테이너의 기본 URL (예: http://192.168.1.10:3333).',
+    mlToken: 'ML API 토큰 (선택 사항)',
+    mlTokenPlaceholder: '서버가 ML_API_TOKEN 없이 실행 중이면 비워 두세요',
+    mlTokenHint: 'Obico ml_api 컨테이너의 ML_API_TOKEN 환경 변수와 일치해야 합니다. 컨테이너가 토큰 없이 실행 중이면 비워 두세요.',
     test: '테스트',
     testSuccess: 'ML API에 도달 가능하며 정상입니다.',
+    testSuccessTokenUnknown: 'ML API에 도달 가능하며 정상입니다. 토큰은 확인할 수 없었습니다.',
     testFailed: 'ML API에 도달할 수 없습니다.',
     sensitivity: '민감도',
     sensitivityLow: '낮음 (오탐 적음)',

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

@@ -6519,8 +6519,12 @@ 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).',
+    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.',
     test: 'Testar',
     testSuccess: 'API ML acessível e operacional.',
+    testSuccessTokenUnknown: 'API ML acessível e operacional. Não foi possível verificar o token.',
     testFailed: 'Não foi possível acessar a API ML.',
     sensitivity: 'Sensibilidade',
     sensitivityLow: 'Baixa (menos falsos positivos)',

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

@@ -6158,8 +6158,12 @@ export default {
     description: "Контролируйте печать через собственный сервер Obico ML API и автоматически реагируйте на обнаруженные сбои.",
     mlUrl: "URL Obico ML API",
     mlUrlHint: "Базовый URL собственного контейнера Obico ml_api, например http://192.168.1.10:3333.",
+    mlToken: "Токен ML API (необязательно)",
+    mlTokenPlaceholder: "Оставьте пустым, если сервер работает без ML_API_TOKEN",
+    mlTokenHint: "Должен совпадать с переменной окружения ML_API_TOKEN вашего контейнера Obico ml_api. Оставьте пустым, если контейнер работает без токена.",
     test: "Проверить",
     testSuccess: "ML API доступен и работает.",
+    testSuccessTokenUnknown: "ML API доступен и работает. Проверить токен не удалось.",
     testFailed: "Не удалось подключиться к ML API.",
     sensitivity: "Чувствительность",
     sensitivityLow: "Низкая (меньше ложных срабатываний)",

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

@@ -6470,8 +6470,12 @@ 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).',
+    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.',
     test: 'Test',
     testSuccess: 'ML API erişilebilir ve sağlıklı.',
+    testSuccessTokenUnknown: 'ML API erişilebilir ve sağlıklı. Belirteç doğrulanamadı.',
     testFailed: 'ML API\'ye erişilemedi.',
     sensitivity: 'Hassasiyet',
     sensitivityLow: 'Düşük (daha az yanlış pozitif)',

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

@@ -6574,8 +6574,12 @@ export default {
     description: "Відстежуйте друк за допомогою самостійно розгорнутого Obico ML API та автоматично реагуйте на виявлені помилки.",
     mlUrl: "URL Obico ML API",
     mlUrlHint: "База URL вашого контейнера ml_api, розміщеного на власному хості Obico (наприклад, http://192.168.1.10:3333).",
+    mlToken: "Токен ML API (необов'язково)",
+    mlTokenPlaceholder: "Залиште порожнім, якщо сервер працює без ML_API_TOKEN",
+    mlTokenHint: "Має збігатися зі змінною середовища ML_API_TOKEN вашого контейнера Obico ml_api. Залиште порожнім, якщо контейнер працює без токена.",
     test: "Тест",
     testSuccess: "ML API доступний і працює належним чином.",
+    testSuccessTokenUnknown: "ML API доступний і працює належним чином. Не вдалося перевірити токен.",
     testFailed: "Не вдалося підключитися до ML API.",
     sensitivity: "Чутливість",
     sensitivityLow: "Низький (менше помилкових спрацьовувань)",

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

@@ -6518,8 +6518,12 @@ export default {
     description: '通过自托管的 Obico ML API 监控打印,并对检测到的故障自动采取行动。',
     mlUrl: 'Obico ML API 地址',
     mlUrlHint: '您自托管的 Obico ml_api 容器的基础 URL(例如 http://192.168.1.10:3333)。',
+    mlToken: 'ML API 令牌(可选)',
+    mlTokenPlaceholder: '如果服务器未设置 ML_API_TOKEN,请留空',
+    mlTokenHint: '必须与您的 Obico ml_api 容器的 ML_API_TOKEN 环境变量一致。如果容器未使用令牌,请留空。',
     test: '测试',
     testSuccess: 'ML API 可访问且正常。',
+    testSuccessTokenUnknown: 'ML API 可访问且正常。无法验证令牌。',
     testFailed: '无法访问 ML API。',
     sensitivity: '灵敏度',
     sensitivityLow: '低(减少误报)',

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

@@ -6518,8 +6518,12 @@ export default {
     description: '透過自託管的 Obico ML API 監控列印,並對偵測到的故障自動採取行動。',
     mlUrl: 'Obico ML API 地址',
     mlUrlHint: '您自託管的 Obico ml_api 容器的基礎 URL(例如 http://192.168.1.10:3333)。',
+    mlToken: 'ML API 權杖(選填)',
+    mlTokenPlaceholder: '如果伺服器未設定 ML_API_TOKEN,請留空',
+    mlTokenHint: '必須與您的 Obico ml_api 容器的 ML_API_TOKEN 環境變數一致。如果容器未使用權杖,請留空。',
     test: '測試',
     testSuccess: 'ML API 可存取且正常。',
+    testSuccessTokenUnknown: 'ML API 可存取且正常。無法驗證權杖。',
     testFailed: '無法存取 ML API。',
     sensitivity: '靈敏度',
     sensitivityLow: '低(減少誤報)',

Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
static/assets/index-CNWKbw8h.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-Bx2Rwvpi.js"></script>
+    <script type="module" crossorigin src="/assets/index-CNWKbw8h.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-oReXTzKG.css">
   </head>
   <body>

Bu fark içinde çok fazla dosya değişikliği olduğu için bazı dosyalar gösterilmiyor