diff --git a/backend/app/api/routes/obico.py b/backend/app/api/routes/obico.py index 232f2d283..b0bf27701 100644 --- a/backend/app/api/routes/obico.py +++ b/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"]), } diff --git a/backend/app/schemas/settings.py b/backend/app/schemas/settings.py index 5dc80d0bd..06636ffe2 100644 --- a/backend/app/schemas/settings.py +++ b/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.""" diff --git a/backend/app/services/obico_detection.py b/backend/app/services/obico_detection.py index fe554fbaa..89cc939e9 100644 --- a/backend/app/services/obico_detection.py +++ b/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 diff --git a/backend/tests/integration/test_obico_api.py b/backend/tests/integration/test_obico_api.py index aab7cc042..9b6a3bee3 100644 --- a/backend/tests/integration/test_obico_api.py +++ b/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).""" diff --git a/backend/tests/unit/test_obico_detection.py b/backend/tests/unit/test_obico_detection.py index 3b21e17c1..e93a57893 100644 --- a/backend/tests/unit/test_obico_detection.py +++ b/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) diff --git a/backend/tests/unit/test_outbound_url_ssrf_guards.py b/backend/tests/unit/test_outbound_url_ssrf_guards.py index 41d00c826..8b958055d 100644 --- a/backend/tests/unit/test_outbound_url_ssrf_guards.py +++ b/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"), diff --git a/frontend/src/__tests__/components/FailureDetectionSettings.test.tsx b/frontend/src/__tests__/components/FailureDetectionSettings.test.tsx index 4ba8bfaf6..70604f080 100644 --- a/frontend/src/__tests__/components/FailureDetectionSettings.test.tsx +++ b/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 | 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; + settings = { ...settings, ...saved }; + return HttpResponse.json(settings); + }), + ); + render(); + 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[] = []; + 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; + puts.push(body); + return HttpResponse.json({ ...baseSettings, ...body }); + }), + ); + render(); + 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, diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 15bb95739..dedb5cbf0 100644 --- a/frontend/src/api/client.ts +++ b/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'; diff --git a/frontend/src/components/FailureDetectionSettings.tsx b/frontend/src/components/FailureDetectionSettings.tsx index b22e5fbdf..befb43283 100644 --- a/frontend/src/components/FailureDetectionSettings.tsx +++ b/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(null); // null = all const [testResult, setTestResult] = useState(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() { )} +
+ + 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 && ( +

+ {t('failureDetection.bambuddyInternalUrlInvalid')} +

+ )} +

{t('failureDetection.bambuddyInternalUrlHint')}

+
+