From eec779395563de15febbce7ae55dabd57085a2b4 Mon Sep 17 00:00:00 2001 From: maziggy Date: Mon, 13 Apr 2026 09:54:26 +0200 Subject: [PATCH] feat(obico): AI print-failure detection via self-hosted Obico ML API (#172) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a Failure Detection tab under Settings that wires Bambuddy to a self-hosted Obico ml_api container — no cloud, no account, no WebSocket. While a print is running, the detection service periodically hands the printer's camera snapshot URL to the ML API and smooths scores over time (30-frame warmup + EWM, alpha=2/13, short/long rolling means) so one noisy frame can't trigger an action. When the smoothed score crosses HIGH, the configured action fires exactly once per print: notify, pause, or pause-and-cut-power (via linked smart plugs). - Backend: new obico_detection + obico_smoothing + obico_actions services, /obico/status and /obico/test-connection routes (SETTINGS_READ / SETTINGS_UPDATE), six obico_* AppSettings fields with validators for sensitivity/action/enabled_printers. - Frontend: FailureDetectionSettings component (enable, ML URL + test, sensitivity, action, poll interval, per-printer monitor list, live status + detection history), new sidebar tab with service-active bullet, toast on save. - Tests: 17 detection unit tests + 15 smoothing unit tests + 4 frontend component tests. - Docs: README bullet, CHANGELOG entry, wiki page under Analytics, website features.html entry. --- CHANGELOG.md | 3 + README.md | 1 + backend/app/api/routes/obico.py | 48 + backend/app/main.py | 5 + backend/app/schemas/settings.py | 62 ++ backend/app/services/obico_actions.py | 83 ++ backend/app/services/obico_detection.py | 244 +++++ backend/app/services/obico_smoothing.py | 105 +++ backend/tests/unit/test_obico_detection.py | 220 +++++ backend/tests/unit/test_obico_smoothing.py | 98 ++ .../FailureDetectionSettings.test.tsx | 123 +++ frontend/src/api/client.ts | 47 + .../components/FailureDetectionSettings.tsx | 374 ++++++++ frontend/src/i18n/locales/de.ts | 33 + frontend/src/i18n/locales/en.ts | 33 + frontend/src/i18n/locales/fr.ts | 33 + frontend/src/i18n/locales/it.ts | 33 + frontend/src/i18n/locales/ja.ts | 33 + frontend/src/i18n/locales/pt-BR.ts | 33 + frontend/src/i18n/locales/zh-CN.ts | 33 + frontend/src/pages/SettingsPage.tsx | 33 +- static/assets/index-CP-2HMi5.css | 1 + static/assets/index-Czpqfgna.css | 1 - .../{index-DfEBMSZy.js => index-Dc-TAKpR.js} | 884 +++++++++--------- static/index.html | 4 +- 25 files changed, 2119 insertions(+), 448 deletions(-) create mode 100644 backend/app/api/routes/obico.py create mode 100644 backend/app/services/obico_actions.py create mode 100644 backend/app/services/obico_detection.py create mode 100644 backend/app/services/obico_smoothing.py create mode 100644 backend/tests/unit/test_obico_detection.py create mode 100644 backend/tests/unit/test_obico_smoothing.py create mode 100644 frontend/src/__tests__/components/FailureDetectionSettings.test.tsx create mode 100644 frontend/src/components/FailureDetectionSettings.tsx create mode 100644 static/assets/index-CP-2HMi5.css delete mode 100644 static/assets/index-Czpqfgna.css rename static/assets/{index-DfEBMSZy.js => index-Dc-TAKpR.js} (66%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3af9fd5b0..753e85b79 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ All notable changes to Bambuddy will be documented in this file. ## [0.2.3b4] - Unreleased +### New Features +- **AI Print-Failure Detection via self-hosted Obico ML API** ([#172](https://github.com/maziggy/bambuddy/issues/172)) — New Settings → Failure Detection tab wires Bambuddy to a self-hosted [Obico](https://github.com/TheSpaghettiDetective/obico-server) `ml_api` container (no Obico account, no cloud, no WebSocket). While a print is running, the detection service periodically hands the printer's camera snapshot URL to the ML API, which returns YOLO failure-detection scores. Scores are smoothed over time using Obico's own EWM + short/long rolling-mean math (30-frame warmup, alpha = 2/13, short window ≈ 5 min at 10s/frame, long window ≈ 20 h) so a single noisy frame cannot trigger an action. Sensitivity (Low / Medium / High) scales the LOW/HIGH thresholds; when the smoothed score crosses HIGH, the configured action runs exactly once per print: *Notify only*, *Pause print* (MQTT pause command), or *Pause and cut power* (pause + turn off any smart plug linked to that printer). A per-printer toggle lets you monitor all connected printers or just a subset. The Status card shows whether the service is running, the active thresholds, each monitored print's current verdict (safe / warning / failure), and a live rolling detection history. Requires that the External URL setting (General tab) points to a hostname/IP reachable from the ML API container, since the ML API fetches snapshots by URL. + ### Fixed - **Clear Plate Confirmation Bypassed on Power Cycle** ([#961](https://github.com/maziggy/bambuddy/issues/961)) — With Auto Off enabled and another job queued, the smart plug would cut power when a print finished and immediately re-power when the scheduler saw the queue, at which point the printer booted fresh into `IDLE` and the next job auto-dispatched without the "Clear Plate & Start Next" confirmation. Root cause: the plate-cleared gate lived only in the in-memory `PrinterManager._plate_cleared` set, and the scheduler's idle check treated `IDLE` as always-idle regardless of whether a previous finish had been acknowledged — so the gate was lost across both Bambuddy restarts and the IDLE-on-boot state transition. The gate is now an `awaiting_plate_clear` column on the `printers` table, set by `on_print_complete` when a print finishes or fails, cleared by the `/printers/{id}/clear-plate` endpoint and by the scheduler when it dispatches the next job, and rehydrated from the DB into `PrinterManager` on startup. `_is_printer_idle` now short-circuits to not-idle whenever `require_plate_clear` is on and the printer is awaiting ack, regardless of the currently reported state — so the prompt survives Auto Off cycles, Bambuddy restarts, and the printer booting back into `IDLE`. The clear-plate endpoint no longer requires the printer to currently report `FINISH`/`FAILED` (it accepts the ack whenever the awaiting flag is set), and the Printers page widget prompts based on the flag rather than the reported state. Thanks to @miaopas for reporting. - **Insecure Temp File Creation in Backup Export** — The manual backup download endpoint used `tempfile.mktemp()`, which is vulnerable to a symlink race condition (CWE-377). Replaced with `tempfile.mkstemp()` which atomically creates the file, eliminating the TOCTOU window. diff --git a/README.md b/README.md index 65cc9ce6e..644d61cb8 100644 --- a/README.md +++ b/README.md @@ -119,6 +119,7 @@ Perfect for remote print farms, traveling makers, or accessing your home printer - Print success rates & trends - Filament usage tracking - Cost analytics & failure analysis +- **AI print-failure detection** — Optional integration with a self-hosted [Obico](https://github.com/TheSpaghettiDetective/obico-server) ML API: watches each running print's camera feed, smooths scores over time (30-frame warmup + EWM + rolling means), and fires a configurable action once per print (notify / pause / pause-and-off) - Per-user statistics filtering (admin permission gated) - CSV/Excel export diff --git a/backend/app/api/routes/obico.py b/backend/app/api/routes/obico.py new file mode 100644 index 000000000..c48412326 --- /dev/null +++ b/backend/app/api/routes/obico.py @@ -0,0 +1,48 @@ +"""API routes for Obico AI failure detection.""" + +import logging + +from fastapi import APIRouter +from pydantic import BaseModel + +from backend.app.core.auth import RequirePermissionIfAuthEnabled +from backend.app.core.permissions import Permission +from backend.app.models.user import User +from backend.app.services.obico_detection import obico_detection_service + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/obico", tags=["obico"]) + + +class TestConnectionRequest(BaseModel): + url: str + + +@router.get("/status") +async def get_status( + _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ), +): + """Scheduler status, per-printer classification, and recent detection history.""" + settings = await obico_detection_service._load_settings() + status = obico_detection_service.get_status() + return { + **status, + "enabled": settings["enabled"], + "ml_url": settings["ml_url"], + "sensitivity": settings["sensitivity"], + "action": settings["action"], + "poll_interval": settings["poll_interval"], + "external_url_configured": bool(settings["external_url"]), + } + + +@router.post("/test-connection") +async def test_connection( + req: TestConnectionRequest, + _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE), +): + """Ping the Obico ML API `/hc/` health endpoint. 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) diff --git a/backend/app/main.py b/backend/app/main.py index d0a6e22d4..4032dbd6a 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -34,6 +34,7 @@ from backend.app.api.routes import ( metrics, notification_templates, notifications, + obico, pending_uploads, print_log, print_queue, @@ -68,6 +69,7 @@ from backend.app.services.local_backup import local_backup_service from backend.app.services.mqtt_relay import mqtt_relay from backend.app.services.mqtt_smart_plug import mqtt_smart_plug_service from backend.app.services.notification_service import notification_service +from backend.app.services.obico_detection import obico_detection_service from backend.app.services.print_scheduler import scheduler as print_scheduler from backend.app.services.printer_manager import ( init_printer_connections, @@ -3922,6 +3924,7 @@ async def lifespan(app: FastAPI): # Start the local backup scheduler await local_backup_service.start_scheduler() + await obico_detection_service.start() # Start AMS history recording start_ams_history_recording() @@ -3958,6 +3961,7 @@ async def lifespan(app: FastAPI): notification_service.stop_digest_scheduler() github_backup_service.stop_scheduler() local_backup_service.stop_scheduler() + obico_detection_service.stop() stop_ams_history_recording() stop_runtime_tracking() stop_spoolbuddy_watchdog() @@ -4190,6 +4194,7 @@ app.include_router(pending_uploads.router, prefix=app_settings.api_prefix) app.include_router(firmware.router, prefix=app_settings.api_prefix) app.include_router(github_backup.router, prefix=app_settings.api_prefix) app.include_router(local_backup.router, prefix=app_settings.api_prefix) +app.include_router(obico.router, prefix=app_settings.api_prefix) app.include_router(metrics.router, prefix=app_settings.api_prefix) app.include_router(virtual_printers.router, prefix=app_settings.api_prefix) app.include_router(spoolbuddy.router, prefix=app_settings.api_prefix) diff --git a/backend/app/schemas/settings.py b/backend/app/schemas/settings.py index 96b00fc92..8dee7c1ec 100644 --- a/backend/app/schemas/settings.py +++ b/backend/app/schemas/settings.py @@ -256,6 +256,31 @@ class AppSettings(BaseModel): description="Fallback BamBuddy group name assigned when an LDAP user authenticates but has no mapped groups. Empty = no fallback.", ) + # Obico AI failure detection (#172) + obico_enabled: bool = Field(default=False, description="Enable Obico AI print failure detection") + obico_ml_url: str = Field( + default="", + description="Self-hosted Obico ML API base URL (e.g., http://192.168.1.10:3333)", + ) + obico_sensitivity: str = Field( + default="medium", + description="Detection sensitivity: 'low', 'medium', or 'high' (adjusts LOW/HIGH thresholds)", + ) + obico_action: str = Field( + default="notify", + description="Action on detected failure: 'notify', 'pause', or 'pause_and_off'", + ) + obico_poll_interval: int = Field( + default=10, + ge=5, + le=120, + description="Seconds between detection checks while a print is running", + ) + obico_enabled_printers: str = Field( + default="", + description="JSON array of printer IDs to monitor (empty = all connected printers)", + ) + # Default sidebar order (admin-set for all users) default_sidebar_order: str = Field( default="", @@ -356,6 +381,12 @@ class AppSettingsUpdate(BaseModel): ldap_group_mapping: str | None = None ldap_auto_provision: bool | None = None ldap_default_group: str | None = None + obico_enabled: bool | None = None + obico_ml_url: 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) + obico_enabled_printers: str | None = None default_sidebar_order: str | None = None @field_validator("gcode_snippets") @@ -384,6 +415,37 @@ class AppSettingsUpdate(BaseModel): raise ValueError("ldap_group_mapping must be a JSON object mapping LDAP group DNs to BamBuddy group names") return v + @field_validator("obico_enabled_printers") + @classmethod + def validate_obico_enabled_printers(cls, v: str | None) -> str | None: + if v is None or v == "": + return v + try: + parsed = json.loads(v) + except json.JSONDecodeError: + raise ValueError("obico_enabled_printers must be valid JSON or empty") + if not isinstance(parsed, list) or not all(isinstance(item, int) for item in parsed): + raise ValueError("obico_enabled_printers must be a JSON array of printer IDs (integers)") + return v + + @field_validator("obico_sensitivity") + @classmethod + def validate_obico_sensitivity(cls, v: str | None) -> str | None: + if v is None: + return v + if v not in ("low", "medium", "high"): + raise ValueError("obico_sensitivity must be 'low', 'medium', or 'high'") + return v + + @field_validator("obico_action") + @classmethod + def validate_obico_action(cls, v: str | None) -> str | None: + if v is None: + return v + if v not in ("notify", "pause", "pause_and_off"): + raise ValueError("obico_action must be 'notify', 'pause', or 'pause_and_off'") + return v + @field_validator("default_sidebar_order") @classmethod def validate_default_sidebar_order(cls, v: str | None) -> str | None: diff --git a/backend/app/services/obico_actions.py b/backend/app/services/obico_actions.py new file mode 100644 index 000000000..2470245c4 --- /dev/null +++ b/backend/app/services/obico_actions.py @@ -0,0 +1,83 @@ +"""Action dispatch for Obico failure detection. + +Separated from the detection loop so actions can be unit-tested and swapped. +""" + +import logging + +from sqlalchemy import select + +from backend.app.core.database import async_session +from backend.app.models.printer import Printer + +logger = logging.getLogger(__name__) + + +async def execute_action(printer_id: int, action: str, task_name: str, score: float) -> None: + """Run the configured action for a detected print failure. + + action: 'notify' | 'pause' | 'pause_and_off' + """ + printer_name = await _get_printer_name(printer_id) + + if action in ("pause", "pause_and_off"): + _pause_print(printer_id) + + if action == "pause_and_off": + await _turn_off_linked_plugs(printer_id) + + await _notify(printer_id, printer_name, task_name, score, action) + + +async def _get_printer_name(printer_id: int) -> str: + async with async_session() as db: + result = await db.execute(select(Printer).where(Printer.id == printer_id)) + printer = result.scalar_one_or_none() + return printer.name if printer else f"Printer {printer_id}" + + +def _pause_print(printer_id: int) -> None: + from backend.app.services.printer_manager import printer_manager + + client = printer_manager.get_client(printer_id) + if not client: + logger.warning("Obico pause: no MQTT client for printer %s", printer_id) + return + if not client.pause_print(): + logger.warning("Obico pause: pause_print() returned False for printer %s", printer_id) + + +async def _turn_off_linked_plugs(printer_id: int) -> None: + from backend.app.services.smart_plug_manager import smart_plug_manager + + async with async_session() as db: + plugs = await smart_plug_manager._get_plugs_for_printer(printer_id, db) + for plug in plugs: + if not plug.enabled: + continue + try: + service = await smart_plug_manager.get_service_for_plug(plug, db) + await service.turn_off(plug) + logger.info("Obico action: turned off plug %s for printer %s", plug.name, printer_id) + except Exception as e: + logger.error("Obico action: failed to turn off plug %s: %s", plug.name, e) + + +async def _notify(printer_id: int, printer_name: str, task_name: str, score: float, action: str) -> None: + from backend.app.services.notification_service import notification_service + + detail = ( + f"Possible print failure detected on '{task_name or 'current job'}' " + f"(confidence {score:.2f}). Action taken: {action}." + ) + async with async_session() as db: + try: + await notification_service.on_printer_error( + printer_id=printer_id, + printer_name=printer_name, + error_type="ai_failure_detection", + db=db, + error_detail=detail, + ) + except Exception as e: + logger.error("Obico notify failed for printer %s: %s", printer_id, e) diff --git a/backend/app/services/obico_detection.py b/backend/app/services/obico_detection.py new file mode 100644 index 000000000..80ceb8e9c --- /dev/null +++ b/backend/app/services/obico_detection.py @@ -0,0 +1,244 @@ +"""Obico AI print-failure detection service. + +Polls a self-hosted Obico ML API with snapshots from each monitored printer +while a print is running, smooths scores over time, and dispatches a configured +action (notify / pause / pause_and_off) when a sustained failure is detected. + +See `obico_smoothing.py` for the per-print EWM + rolling-mean math. +""" + +import asyncio +import json +import logging +from collections import deque +from datetime import datetime, timezone + +import httpx +from sqlalchemy import select + +from backend.app.core.database import async_session +from backend.app.models.settings import Settings +from backend.app.services.obico_smoothing import ( + PrintState, + classify, + score_from_detections, + thresholds, +) + +logger = logging.getLogger(__name__) + +HISTORY_MAX = 50 +HEALTH_TIMEOUT = 5.0 +DETECTION_TIMEOUT = 30.0 + + +class ObicoDetectionService: + """Singleton service that polls the ML API and acts on sustained failures.""" + + def __init__(self): + self._task: asyncio.Task | None = None + # printer_id -> PrintState (reset when a new print starts) + self._states: dict[int, PrintState] = {} + # printer_id -> task_name active when state was created (used to detect new prints) + self._state_keys: dict[int, str] = {} + # printer_id -> last classification ("safe"/"warning"/"failure") + self._last_class: dict[int, str] = {} + # printer_id -> whether an action has already been fired for the current print + self._action_fired: dict[int, bool] = {} + # Global detection event log (most-recent-first) + self._history: deque = deque(maxlen=HISTORY_MAX) + self._last_error: str | None = None + + # ---- lifecycle ---- + + async def start(self): + if self._task is not None: + return + logger.info("Starting Obico detection service") + self._task = asyncio.create_task(self._loop()) + + def stop(self): + if self._task: + self._task.cancel() + self._task = None + logger.info("Stopped Obico detection service") + + # ---- settings ---- + + async def _load_settings(self) -> dict: + keys = [ + "obico_enabled", + "obico_ml_url", + "obico_sensitivity", + "obico_action", + "obico_poll_interval", + "obico_enabled_printers", + "external_url", + ] + async with async_session() as db: + result = await db.execute(select(Settings).where(Settings.key.in_(keys))) + rows = {r.key: r.value for r in result.scalars().all()} + + enabled_printers_raw = rows.get("obico_enabled_printers", "") + if enabled_printers_raw: + try: + enabled_printers = set(json.loads(enabled_printers_raw)) + except json.JSONDecodeError: + enabled_printers = set() + else: + enabled_printers = None # None = all printers + + return { + "enabled": rows.get("obico_enabled", "false").lower() == "true", + "ml_url": (rows.get("obico_ml_url") or "").rstrip("/"), + "sensitivity": rows.get("obico_sensitivity", "medium"), + "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("/"), + } + + # ---- main loop ---- + + async def _loop(self): + """Poll active printers while enabled. Adjusts interval from settings each cycle.""" + while True: + try: + settings = await self._load_settings() + interval = max(5, settings.get("poll_interval", 10)) + if not settings["enabled"] or not settings["ml_url"]: + await asyncio.sleep(interval) + continue + if not settings["external_url"]: + # Without a reachable base URL, the ML API can't fetch snapshots. + self._last_error = "external_url not set — ML API cannot reach snapshot endpoint" + await asyncio.sleep(interval) + continue + + await self._poll_once(settings) + await asyncio.sleep(interval) + except asyncio.CancelledError: + break + except Exception as e: + logger.error("Obico detection loop error: %s", e) + self._last_error = str(e) + await asyncio.sleep(30) + + async def _poll_once(self, settings: dict): + # Late import to avoid cycles at module load time + from backend.app.services.printer_manager import printer_manager + + statuses = printer_manager.get_all_statuses() + for printer_id, status in list(statuses.items()): + if settings["enabled_printers"] is not None and printer_id not in settings["enabled_printers"]: + continue + if not printer_manager.is_connected(printer_id): + continue + if not status or getattr(status, "state", None) != "RUNNING": + # Reset state when not printing so the next print starts fresh + self._states.pop(printer_id, None) + self._state_keys.pop(printer_id, None) + self._action_fired.pop(printer_id, None) + continue + + await self._check_printer(printer_id, status, settings) + + async def _check_printer(self, printer_id: int, status, settings: dict): + task_name = getattr(status, "task_name", None) or getattr(status, "subtask_name", "") or "" + key = f"{task_name}" + if self._state_keys.get(printer_id) != key: + self._states[printer_id] = PrintState() + self._state_keys[printer_id] = key + self._action_fired[printer_id] = False + + snapshot_url = f"{settings['external_url']}/api/v1/printers/{printer_id}/camera/snapshot" + ml_url = f"{settings['ml_url']}/p/" + + try: + async with httpx.AsyncClient(timeout=DETECTION_TIMEOUT) as client: + resp = await client.get(ml_url, params={"img": snapshot_url}) + resp.raise_for_status() + payload = resp.json() + except Exception as e: + self._last_error = f"ML API call failed for printer {printer_id}: {e}" + logger.warning(self._last_error) + return + + detections = payload.get("detections", []) if isinstance(payload, dict) else [] + current_p = score_from_detections(detections) + state = self._states[printer_id] + score = state.update(current_p) + verdict = classify(score, settings["sensitivity"]) + self._last_class[printer_id] = verdict + + # Log every non-safe sample — safe samples would flood history + if verdict != "safe" or detections: + self._history.appendleft( + { + "printer_id": printer_id, + "task_name": task_name, + "timestamp": datetime.now(timezone.utc).isoformat(), + "current_p": round(current_p, 4), + "score": round(score, 4), + "class": verdict, + "detections": len(detections), + } + ) + + if verdict == "failure" and not self._action_fired.get(printer_id): + self._action_fired[printer_id] = True + await self._dispatch_action(printer_id, settings["action"], task_name, score) + + async def _dispatch_action(self, printer_id: int, action: str, task_name: str, score: float): + from backend.app.services.obico_actions import execute_action + + logger.warning( + "Obico: failure detected on printer %s (task=%r score=%.3f) — action=%s", + printer_id, + task_name, + score, + action, + ) + try: + await execute_action(printer_id, action, task_name, score) + except Exception as e: + self._last_error = f"Action dispatch failed: {e}" + logger.error(self._last_error) + + # ---- queries ---- + + def get_status(self) -> dict: + low, high = thresholds("medium") + return { + "is_running": self._task is not None and not self._task.done(), + "last_error": self._last_error, + "per_printer": { + pid: { + "class": self._last_class.get(pid, "safe"), + "frame_count": state.frame_count, + "score": round(state.ewm_mean, 4), + } + for pid, state in self._states.items() + }, + "thresholds": {"low": low, "high": high}, + "history": list(self._history), + } + + async def test_connection(self, url: str) -> dict: + """Ping the ML API health endpoint. Returns {ok, status_code, body, error}.""" + target = f"{url.rstrip('/')}/hc/" + try: + async with httpx.AsyncClient(timeout=HEALTH_TIMEOUT) as client: + resp = await client.get(target) + body = resp.text.strip() + return { + "ok": resp.status_code == 200 and body.lower() == "ok", + "status_code": resp.status_code, + "body": body, + "error": None, + } + except Exception as e: + return {"ok": False, "status_code": None, "body": None, "error": str(e)} + + +obico_detection_service = ObicoDetectionService() diff --git a/backend/app/services/obico_smoothing.py b/backend/app/services/obico_smoothing.py new file mode 100644 index 000000000..43ee2fed8 --- /dev/null +++ b/backend/app/services/obico_smoothing.py @@ -0,0 +1,105 @@ +"""Temporal smoothing for Obico ML detection scores. + +Ports Obico's failure-detection math: +- per-frame `current_p` = sum of detection confidences +- `ewm_mean` = exponentially weighted mean (alpha = 2 / (span + 1), span = 12) +- `rolling_mean_short` = ~310 frames of recent activity (≈52 min at 10s/frame) +- `rolling_mean_long` = ~7200 frames of long-term baseline noise +- First `WARMUP_FRAMES` frames always report "safe" while the state settles +- Final score = max(ewm_mean, rolling_mean_short - rolling_mean_long) +- Thresholds: LOW < score < HIGH is "warning", >= HIGH is "failure" +""" + +import math +from collections import deque +from dataclasses import dataclass, field + +EWM_SPAN = 12 +EWM_ALPHA = 2.0 / (EWM_SPAN + 1) +ROLLING_SHORT = 310 +ROLLING_LONG = 7200 +WARMUP_FRAMES = 30 + +# Base thresholds; sensitivity multipliers adjust them +BASE_LOW = 0.38 +BASE_HIGH = 0.78 + +SENSITIVITY_MULT = { + "low": 1.25, # harder to trigger — higher thresholds + "medium": 1.0, + "high": 0.75, # easier to trigger — lower thresholds +} + + +def thresholds(sensitivity: str) -> tuple[float, float]: + mult = SENSITIVITY_MULT.get(sensitivity, 1.0) + return BASE_LOW * mult, BASE_HIGH * mult + + +@dataclass +class PrintState: + """Per-print smoothing state. Reset when a new print starts.""" + + frame_count: int = 0 + ewm_mean: float = 0.0 + short_sum: float = 0.0 + long_sum: float = 0.0 + short_buf: deque = field(default_factory=lambda: deque(maxlen=ROLLING_SHORT)) + long_buf: deque = field(default_factory=lambda: deque(maxlen=ROLLING_LONG)) + + def update(self, current_p: float) -> float: + """Feed a new per-frame score and return the smoothed score. + + Returns 0.0 during warmup so early noise doesn't trigger actions. + """ + self.frame_count += 1 + + if self.frame_count == 1: + self.ewm_mean = current_p + else: + self.ewm_mean = EWM_ALPHA * current_p + (1 - EWM_ALPHA) * self.ewm_mean + + if len(self.short_buf) == self.short_buf.maxlen: + self.short_sum -= self.short_buf[0] + self.short_buf.append(current_p) + self.short_sum += current_p + + if len(self.long_buf) == self.long_buf.maxlen: + self.long_sum -= self.long_buf[0] + self.long_buf.append(current_p) + self.long_sum += current_p + + if self.frame_count <= WARMUP_FRAMES: + return 0.0 + + short_mean = self.short_sum / len(self.short_buf) + long_mean = self.long_sum / len(self.long_buf) + return max(self.ewm_mean, short_mean - long_mean) + + +def classify(score: float, sensitivity: str) -> str: + """Return 'safe', 'warning', or 'failure' for a smoothed score.""" + low, high = thresholds(sensitivity) + if score >= high: + return "failure" + if score >= low: + return "warning" + return "safe" + + +def score_from_detections(detections: list) -> float: + """Sum confidences from the ML API `detections` array. + + Each detection is `[label, confidence, [x, y, w, h]]`. We only care about + the confidence column — label is always "failure" for the single-class model. + """ + total = 0.0 + for det in detections or []: + try: + value = float(det[1]) + except (IndexError, TypeError, ValueError): + continue + if math.isnan(value) or math.isinf(value): + continue + total += value + return total diff --git a/backend/tests/unit/test_obico_detection.py b/backend/tests/unit/test_obico_detection.py new file mode 100644 index 000000000..91fec50ce --- /dev/null +++ b/backend/tests/unit/test_obico_detection.py @@ -0,0 +1,220 @@ +"""Unit tests for Obico detection service (#172).""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from backend.app.schemas.settings import AppSettingsUpdate +from backend.app.services.obico_detection import ObicoDetectionService +from backend.app.services.obico_smoothing import WARMUP_FRAMES + + +class TestSettingsSchemaValidators: + """Guard rails on the new obico_* AppSettings fields.""" + + def test_sensitivity_accepts_valid_values(self): + for value in ("low", "medium", "high"): + u = AppSettingsUpdate(obico_sensitivity=value) + assert u.obico_sensitivity == value + + def test_sensitivity_rejects_garbage(self): + with pytest.raises(ValueError, match="obico_sensitivity"): + AppSettingsUpdate(obico_sensitivity="extreme") + + def test_action_accepts_valid_values(self): + for value in ("notify", "pause", "pause_and_off"): + assert AppSettingsUpdate(obico_action=value).obico_action == value + + def test_action_rejects_garbage(self): + with pytest.raises(ValueError, match="obico_action"): + AppSettingsUpdate(obico_action="explode") + + def test_enabled_printers_accepts_empty(self): + assert AppSettingsUpdate(obico_enabled_printers="").obico_enabled_printers == "" + assert AppSettingsUpdate(obico_enabled_printers=None).obico_enabled_printers is None + + def test_enabled_printers_accepts_int_array(self): + u = AppSettingsUpdate(obico_enabled_printers="[1, 2, 3]") + assert u.obico_enabled_printers == "[1, 2, 3]" + + def test_enabled_printers_rejects_non_json(self): + with pytest.raises(ValueError, match="valid JSON"): + AppSettingsUpdate(obico_enabled_printers="1,2,3") + + def test_enabled_printers_rejects_non_list(self): + with pytest.raises(ValueError, match="JSON array"): + AppSettingsUpdate(obico_enabled_printers='{"1": true}') + + def test_enabled_printers_rejects_non_int_elements(self): + with pytest.raises(ValueError, match="JSON array"): + AppSettingsUpdate(obico_enabled_printers='[1, "two"]') + + def test_poll_interval_bounds(self): + with pytest.raises(ValueError): + AppSettingsUpdate(obico_poll_interval=4) + with pytest.raises(ValueError): + AppSettingsUpdate(obico_poll_interval=121) + assert AppSettingsUpdate(obico_poll_interval=10).obico_poll_interval == 10 + + +class TestGetStatus: + def test_empty_initial_status(self): + svc = ObicoDetectionService() + s = svc.get_status() + assert s["is_running"] is False + assert s["per_printer"] == {} + assert s["history"] == [] + assert "low" in s["thresholds"] and "high" in s["thresholds"] + + +class TestTestConnection: + @pytest.mark.asyncio + async def test_empty_url_via_route(self): + """Service does not special-case empty URL — the route does.""" + svc = ObicoDetectionService() + # This will fail DNS/connect, but should return ok=False + result = await svc.test_connection("http://nonexistent-obico-host-xyz.invalid:3333") + assert result["ok"] is False + assert result["error"] is not None + + @pytest.mark.asyncio + async def test_healthy_response_is_ok(self): + svc = ObicoDetectionService() + mock_response = MagicMock(status_code=200, text="ok") + mock_client = MagicMock() + mock_client.get = AsyncMock(return_value=mock_response) + 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): + result = await svc.test_connection("http://obico:3333") + assert result["ok"] is True + assert result["status_code"] == 200 + assert result["body"] == "ok" + assert result["error"] is None + + @pytest.mark.asyncio + async def test_non_ok_body_is_not_ok(self): + svc = ObicoDetectionService() + mock_response = MagicMock(status_code=200, text="something else") + mock_client = MagicMock() + mock_client.get = AsyncMock(return_value=mock_response) + 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): + result = await svc.test_connection("http://obico:3333/") + assert result["ok"] is False + assert result["body"] == "something else" + + +class TestPollOneStateLifecycle: + """Confirms per-printer state is reset when a new print starts.""" + + @pytest.mark.asyncio + async def test_new_task_name_resets_state(self): + svc = ObicoDetectionService() + # Seed a state that has been running for a while + from backend.app.services.obico_smoothing import PrintState + + seeded = PrintState() + for _ in range(WARMUP_FRAMES + 5): + seeded.update(0.5) + svc._states[1] = seeded + svc._state_keys[1] = "old_task" + svc._action_fired[1] = True + + settings = { + "enabled": True, + "ml_url": "http://obico:3333", + "sensitivity": "medium", + "action": "notify", + "poll_interval": 10, + "enabled_printers": None, + "external_url": "http://bambuddy:8000", + } + status = MagicMock(state="RUNNING", task_name="new_task", subtask_name="") + + mock_response = MagicMock() + mock_response.json.return_value = {"detections": []} + mock_response.raise_for_status = MagicMock() + mock_client = MagicMock() + mock_client.get = AsyncMock(return_value=mock_response) + 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): + await svc._check_printer(1, status, settings) + + # State was reset (frame_count is 1 after the single update, not 36) + assert svc._states[1].frame_count == 1 + assert svc._state_keys[1] == "new_task" + assert svc._action_fired[1] is False + + @pytest.mark.asyncio + async def test_ml_api_error_does_not_crash(self): + svc = ObicoDetectionService() + settings = { + "enabled": True, + "ml_url": "http://obico:3333", + "sensitivity": "medium", + "action": "notify", + "poll_interval": 10, + "enabled_printers": None, + "external_url": "http://bambuddy:8000", + } + status = MagicMock(state="RUNNING", task_name="job", subtask_name="") + + mock_client = MagicMock() + mock_client.get = AsyncMock(side_effect=RuntimeError("connection refused")) + 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): + await svc._check_printer(1, status, settings) + + assert svc._last_error is not None + assert "connection refused" in svc._last_error + + @pytest.mark.asyncio + async def test_failure_fires_action_only_once(self): + """Once a failure has fired for a print, subsequent failures should not re-fire.""" + svc = ObicoDetectionService() + settings = { + "enabled": True, + "ml_url": "http://obico:3333", + "sensitivity": "medium", + "action": "notify", + "poll_interval": 10, + "enabled_printers": None, + "external_url": "http://bambuddy:8000", + } + status = MagicMock(state="RUNNING", task_name="job", subtask_name="") + + # Seed state so the next frame crosses HIGH immediately + from backend.app.services.obico_smoothing import PrintState + + seeded = PrintState() + for _ in range(WARMUP_FRAMES + 500): + seeded.update(1.0) + svc._states[1] = seeded + svc._state_keys[1] = "job" + svc._action_fired[1] = False + + mock_response = MagicMock() + mock_response.json.return_value = {"detections": [["failure", 0.9, [0, 0, 1, 1]]]} + mock_response.raise_for_status = MagicMock() + mock_client = MagicMock() + mock_client.get = AsyncMock(return_value=mock_response) + 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("backend.app.services.obico_actions.execute_action", new=AsyncMock()) as mock_action, + ): + await svc._check_printer(1, status, settings) + assert mock_action.call_count == 1 + await svc._check_printer(1, status, settings) + # Second call must not dispatch again + assert mock_action.call_count == 1 diff --git a/backend/tests/unit/test_obico_smoothing.py b/backend/tests/unit/test_obico_smoothing.py new file mode 100644 index 000000000..842483b2c --- /dev/null +++ b/backend/tests/unit/test_obico_smoothing.py @@ -0,0 +1,98 @@ +"""Unit tests for Obico detection smoothing math.""" + +import pytest + +from backend.app.services.obico_smoothing import ( + BASE_HIGH, + BASE_LOW, + WARMUP_FRAMES, + PrintState, + classify, + score_from_detections, + thresholds, +) + + +class TestThresholds: + def test_medium_matches_base(self): + low, high = thresholds("medium") + assert low == pytest.approx(BASE_LOW) + assert high == pytest.approx(BASE_HIGH) + + def test_low_sensitivity_is_stricter(self): + low, high = thresholds("low") + assert low > BASE_LOW + assert high > BASE_HIGH + + def test_high_sensitivity_is_looser(self): + low, high = thresholds("high") + assert low < BASE_LOW + assert high < BASE_HIGH + + def test_unknown_falls_back_to_medium(self): + assert thresholds("bogus") == thresholds("medium") + + +class TestScoreFromDetections: + def test_empty(self): + assert score_from_detections([]) == 0.0 + assert score_from_detections(None) == 0.0 + + def test_sums_confidences(self): + dets = [["failure", 0.3, [0, 0, 10, 10]], ["failure", 0.5, [0, 0, 10, 10]]] + assert score_from_detections(dets) == pytest.approx(0.8) + + def test_ignores_malformed(self): + dets = [["failure", 0.4, []], ["bad"], ["failure", "nan", []]] + assert score_from_detections(dets) == pytest.approx(0.4) + + +class TestPrintState: + def test_warmup_returns_zero(self): + state = PrintState() + for _ in range(WARMUP_FRAMES): + assert state.update(0.9) == 0.0 + + def test_after_warmup_returns_nonzero_for_hits(self): + state = PrintState() + for _ in range(WARMUP_FRAMES): + state.update(0.9) + score = state.update(0.9) + assert score > 0.0 + + def test_sustained_zero_stays_safe(self): + state = PrintState() + scores = [state.update(0.0) for _ in range(WARMUP_FRAMES + 50)] + assert max(scores) == 0.0 + + def test_sustained_hits_eventually_cross_high(self): + """A stream of high-confidence frames must escalate to 'failure'.""" + state = PrintState() + final = 0.0 + for _ in range(WARMUP_FRAMES + 200): + final = state.update(1.0) + _, high = thresholds("medium") + assert final >= high + + def test_isolated_spike_does_not_trigger_failure(self): + """A single noisy frame in a clean stream must not cross HIGH.""" + state = PrintState() + for _ in range(WARMUP_FRAMES): + state.update(0.0) + score = state.update(1.0) + _, high = thresholds("medium") + assert score < high + + +class TestClassify: + def test_safe(self): + assert classify(0.0, "medium") == "safe" + assert classify(BASE_LOW - 0.01, "medium") == "safe" + + def test_warning(self): + assert classify(BASE_LOW, "medium") == "warning" + assert classify((BASE_LOW + BASE_HIGH) / 2, "medium") == "warning" + + def test_failure(self): + assert classify(BASE_HIGH, "medium") == "failure" + assert classify(1.0, "medium") == "failure" diff --git a/frontend/src/__tests__/components/FailureDetectionSettings.test.tsx b/frontend/src/__tests__/components/FailureDetectionSettings.test.tsx new file mode 100644 index 000000000..576114f44 --- /dev/null +++ b/frontend/src/__tests__/components/FailureDetectionSettings.test.tsx @@ -0,0 +1,123 @@ +/** + * Tests for the Failure Detection settings component (#172). + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { render } from '../utils'; +import { FailureDetectionSettings } from '../../components/FailureDetectionSettings'; +import { http, HttpResponse } from 'msw'; +import { server } from '../mocks/server'; + +const baseSettings = { + auto_archive: true, + save_thumbnails: true, + capture_finish_photo: true, + default_filament_cost: 25, + currency: 'USD', + energy_cost_per_kwh: 0.15, + energy_tracking_mode: 'total', + check_updates: true, + check_printer_firmware: true, + include_beta_updates: false, + obico_enabled: false, + obico_ml_url: '', + obico_sensitivity: 'medium', + obico_action: 'notify', + obico_poll_interval: 10, + obico_enabled_printers: '', +}; + +const baseStatus = { + is_running: true, + last_error: null, + per_printer: {}, + thresholds: { low: 0.38, high: 0.78 }, + history: [], + enabled: false, + ml_url: '', + sensitivity: 'medium', + action: 'notify', + poll_interval: 10, + external_url_configured: true, +}; + +describe('FailureDetectionSettings', () => { + beforeEach(() => { + vi.clearAllMocks(); + server.use( + http.get('/api/v1/settings/', () => HttpResponse.json(baseSettings)), + http.get('/api/v1/obico/status', () => HttpResponse.json(baseStatus)), + http.get('/api/v1/printers', () => HttpResponse.json([])), + ); + }); + + it('renders headings and fields', async () => { + render(); + await waitFor(() => { + expect(screen.getByText(/AI Failure Detection|Failure Detection/i)).toBeInTheDocument(); + }); + expect(screen.getByText(/Obico ML API URL/i)).toBeInTheDocument(); + expect(screen.getByText(/Sensitivity/i)).toBeInTheDocument(); + }); + + it('warns when external URL is missing and detection is enabled', async () => { + server.use( + http.get('/api/v1/settings/', () => HttpResponse.json({ ...baseSettings, obico_enabled: true })), + http.get('/api/v1/obico/status', () => + HttpResponse.json({ ...baseStatus, enabled: true, external_url_configured: false }), + ), + ); + render(); + await waitFor(() => { + expect(screen.getByText(/External URL is not set/i)).toBeInTheDocument(); + }); + }); + + it('test button calls the test-connection endpoint and shows success', async () => { + let called = false; + server.use( + http.get('/api/v1/settings/', () => + HttpResponse.json({ ...baseSettings, obico_enabled: true, obico_ml_url: 'http://obico:3333' }), + ), + http.post('/api/v1/obico/test-connection', async ({ request }) => { + called = true; + const body = (await request.json()) as { url: string }; + expect(body.url).toBe('http://obico:3333'); + return HttpResponse.json({ ok: true, status_code: 200, body: 'ok', error: null }); + }), + ); + render(); + const testBtn = await screen.findByRole('button', { name: /test/i }); + await userEvent.click(testBtn); + await waitFor(() => { + expect(called).toBe(true); + }); + expect(await screen.findByText(/ML API reachable/i)).toBeInTheDocument(); + }); + + it('shows failure class history entries with red styling', async () => { + server.use( + http.get('/api/v1/obico/status', () => + HttpResponse.json({ + ...baseStatus, + history: [ + { + printer_id: 1, + task_name: 'test.3mf', + timestamp: '2026-04-13T10:00:00Z', + current_p: 0.9, + score: 0.85, + class: 'failure', + detections: 1, + }, + ], + }), + ), + ); + render(); + // Match the history row's score-and-class text, which looks like "failure 0.850" + expect(await screen.findByText(/failure\s+0\.850/)).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index c55f35947..1c69dfe98 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -925,6 +925,12 @@ export interface AppSettings { ldap_group_mapping: string; ldap_auto_provision: boolean; ldap_default_group: string; + obico_enabled: boolean; + obico_ml_url: string; + obico_sensitivity: 'low' | 'medium' | 'high'; + obico_action: 'notify' | 'pause' | 'pause_and_off'; + obico_poll_interval: number; + obico_enabled_printers: string; } export type AppSettingsUpdate = Partial; @@ -1832,6 +1838,37 @@ export interface LocalBackupFile { created_at: string; } +export interface ObicoDetectionEvent { + printer_id: number; + task_name: string; + timestamp: string; + current_p: number; + score: number; + class: 'safe' | 'warning' | 'failure'; + detections: number; +} + +export interface ObicoStatus { + is_running: boolean; + last_error: string | null; + per_printer: Record; + thresholds: { low: number; high: number }; + history: ObicoDetectionEvent[]; + enabled: boolean; + ml_url: string; + sensitivity: 'low' | 'medium' | 'high'; + action: 'notify' | 'pause' | 'pause_and_off'; + poll_interval: number; + external_url_configured: boolean; +} + +export interface ObicoTestConnection { + ok: boolean; + status_code: number | null; + body: string | null; + error: string | null; +} + export interface GitHubTestConnectionResponse { success: boolean; message: string; @@ -4518,6 +4555,16 @@ export const api = { deleteLocalBackup: (filename: string) => request<{ success: boolean; message: string }>(`/local-backup/backups/${encodeURIComponent(filename)}`, { method: 'DELETE' }), + // Obico AI failure detection + getObicoStatus: () => + request('/obico/status'), + + testObicoConnection: (url: string) => + request('/obico/test-connection', { + method: 'POST', + body: JSON.stringify({ url }), + }), + // Local Presets (OrcaSlicer imports) getLocalPresets: () => request('/local-presets/'), diff --git a/frontend/src/components/FailureDetectionSettings.tsx b/frontend/src/components/FailureDetectionSettings.tsx new file mode 100644 index 000000000..83ac493a0 --- /dev/null +++ b/frontend/src/components/FailureDetectionSettings.tsx @@ -0,0 +1,374 @@ +import { useState, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { Loader2, ScanEye, Check, X, AlertTriangle, Info } from 'lucide-react'; +import { api } from '../api/client'; +import { Card, CardContent, CardHeader } from './Card'; +import { Button } from './Button'; +import { Toggle } from './Toggle'; +import { useToast } from '../contexts/ToastContext'; + +type TestResult = { ok: boolean; message: string } | null; + +export function FailureDetectionSettings() { + const { t } = useTranslation(); + const queryClient = useQueryClient(); + const { showToast } = useToast(); + + const [enabled, setEnabled] = useState(false); + const [mlUrl, setMlUrl] = useState(''); + const [sensitivity, setSensitivity] = useState<'low' | 'medium' | 'high'>('medium'); + const [action, setAction] = useState<'notify' | 'pause' | 'pause_and_off'>('notify'); + const [pollInterval, setPollInterval] = useState(10); + const [enabledPrinters, setEnabledPrinters] = useState(null); // null = all + const [testResult, setTestResult] = useState(null); + const [initialized, setInitialized] = useState(false); + + const { data: settings } = useQuery({ + queryKey: ['settings'], + queryFn: api.getSettings, + }); + + const { data: status, refetch: refetchStatus } = useQuery({ + queryKey: ['obico-status'], + queryFn: api.getObicoStatus, + refetchInterval: 10000, + }); + + const { data: printers } = useQuery({ + queryKey: ['printers'], + queryFn: api.getPrinters, + }); + + useEffect(() => { + if (!settings) return; + setEnabled(settings.obico_enabled ?? false); + setMlUrl(settings.obico_ml_url ?? ''); + setSensitivity(settings.obico_sensitivity ?? 'medium'); + setAction(settings.obico_action ?? 'notify'); + setPollInterval(settings.obico_poll_interval ?? 10); + try { + const list = settings.obico_enabled_printers + ? (JSON.parse(settings.obico_enabled_printers) as number[]) + : null; + setEnabledPrinters(Array.isArray(list) ? list : null); + } catch { + setEnabledPrinters(null); + } + setInitialized(true); + }, [settings]); + + const saveMutation = useMutation({ + mutationFn: () => + api.updateSettings({ + obico_enabled: enabled, + obico_ml_url: mlUrl, + obico_sensitivity: sensitivity, + obico_action: action, + obico_poll_interval: pollInterval, + obico_enabled_printers: enabledPrinters === null ? '' : JSON.stringify(enabledPrinters), + }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['settings'] }); + queryClient.invalidateQueries({ queryKey: ['obico-status'] }); + showToast(t('settings.toast.settingsSaved')); + }, + }); + + // Auto-save on change (debounced) + useEffect(() => { + if (!initialized || !settings) return; + const changed = + settings.obico_enabled !== enabled || + settings.obico_ml_url !== mlUrl || + settings.obico_sensitivity !== sensitivity || + settings.obico_action !== action || + settings.obico_poll_interval !== pollInterval || + settings.obico_enabled_printers !== (enabledPrinters === null ? '' : JSON.stringify(enabledPrinters)); + if (!changed) return; + const id = setTimeout(() => saveMutation.mutate(), 500); + return () => clearTimeout(id); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [enabled, mlUrl, sensitivity, action, pollInterval, enabledPrinters, initialized]); + + const handleTest = async () => { + setTestResult(null); + try { + const res = await api.testObicoConnection(mlUrl); + if (res.ok) { + setTestResult({ ok: true, message: t('failureDetection.testSuccess') }); + } else { + setTestResult({ + ok: false, + message: res.error || `HTTP ${res.status_code ?? '?'} — ${res.body ?? t('failureDetection.testFailed')}`, + }); + } + } catch (e: unknown) { + setTestResult({ ok: false, message: e instanceof Error ? e.message : String(e) }); + } + }; + + const togglePrinter = (printerId: number, checked: boolean) => { + if (enabledPrinters === null) { + // switch from "all" to an explicit list + const allIds = printers?.map((p) => p.id) ?? []; + const next = checked ? allIds : allIds.filter((id) => id !== printerId); + setEnabledPrinters(next); + return; + } + if (checked) { + setEnabledPrinters([...enabledPrinters, printerId]); + } else { + setEnabledPrinters(enabledPrinters.filter((id) => id !== printerId)); + } + }; + + return ( +
+
+ + +
+
+ +

{t('failureDetection.title')}

+
+ +
+

{t('failureDetection.description')}

+
+ +
+ +
+ setMlUrl(e.target.value)} + placeholder="http://192.168.1.10:3333" + className="flex-1 bg-gray-800 border border-gray-700 rounded px-3 py-2 text-white text-sm" + disabled={!enabled} + /> + +
+

{t('failureDetection.mlUrlHint')}

+ {testResult && ( +
+ {testResult.ok ? : } + {testResult.message} +
+ )} +
+ +
+ + +

{t('failureDetection.sensitivityHint')}

+
+ +
+ + +
+ +
+ + setPollInterval(Math.max(5, Math.min(120, Number(e.target.value) || 10)))} + min={5} + max={120} + disabled={!enabled} + className="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-white text-sm" + /> +

{t('failureDetection.pollIntervalHint')}

+
+ + {status && !status.external_url_configured && enabled && ( +
+ +
+
{t('failureDetection.externalUrlMissing')}
+
{t('failureDetection.externalUrlHint')}
+
+
+ )} +
+
+ + + +

{t('failureDetection.perPrinterTitle')}

+

{t('failureDetection.perPrinterHint')}

+
+ + + {enabledPrinters !== null && printers && ( +
+ {printers.map((p) => ( + + ))} +
+ )} +
+
+
+ +
+ + +

{t('failureDetection.statusTitle')}

+
+ + {!status ? ( +
+ + {t('common.loading')} +
+ ) : ( +
+
+ {t('failureDetection.serviceRunning')} + + {status.is_running ? t('common.yes') : t('common.no')} + +
+
+ {t('failureDetection.thresholds')} + + {status.thresholds.low.toFixed(2)} / {status.thresholds.high.toFixed(2)} + +
+ {status.last_error && ( +
+ + {status.last_error} +
+ )} +
+
{t('failureDetection.activePrinters')}
+ {Object.keys(status.per_printer).length === 0 ? ( +
{t('failureDetection.noActivePrints')}
+ ) : ( +
+ {Object.entries(status.per_printer).map(([pid, info]) => { + const printer = printers?.find((p) => String(p.id) === pid); + const colorClass = + info.class === 'failure' + ? 'text-red-400' + : info.class === 'warning' + ? 'text-amber-400' + : 'text-green-400'; + return ( +
+ {printer?.name ?? `Printer ${pid}`} + + {info.class} ({info.score.toFixed(3)}, {info.frame_count}f) + +
+ ); + })} +
+ )} +
+
+ )} +
+
+ + + +
+

{t('failureDetection.historyTitle')}

+ +
+
+ + {!status || status.history.length === 0 ? ( +
+ + {t('failureDetection.noHistory')} +
+ ) : ( +
+ {status.history.map((ev, idx) => { + const printer = printers?.find((p) => p.id === ev.printer_id); + const colorClass = + ev.class === 'failure' + ? 'text-red-400' + : ev.class === 'warning' + ? 'text-amber-400' + : 'text-bambu-gray'; + return ( +
+ {new Date(ev.timestamp).toLocaleTimeString()} + {printer?.name ?? `#${ev.printer_id}`} + + {ev.class} {ev.score.toFixed(3)} + +
+ ); + })} +
+ )} +
+
+
+
+ ); +} diff --git a/frontend/src/i18n/locales/de.ts b/frontend/src/i18n/locales/de.ts index e7619fd95..0011d2968 100644 --- a/frontend/src/i18n/locales/de.ts +++ b/frontend/src/i18n/locales/de.ts @@ -1308,6 +1308,7 @@ export default { apiKeys: 'API-Schlüssel', virtualPrinter: 'Virtueller Drucker', spoolbuddy: 'SpoolBuddy', + failureDetection: 'Fehlererkennung', users: 'Authentifizierung', backup: 'Sicherung', emailAuth: 'E-Mail-Authentifizierung', @@ -4846,4 +4847,36 @@ export default { viewIssue: 'Issue ansehen', unexpectedError: 'Ein unerwarteter Fehler ist aufgetreten', }, + failureDetection: { + title: 'KI-Fehlererkennung', + 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).', + test: 'Testen', + testSuccess: 'ML-API erreichbar und funktionsfähig.', + testFailed: 'ML-API konnte nicht erreicht werden.', + sensitivity: 'Empfindlichkeit', + sensitivityLow: 'Niedrig (weniger Fehlalarme)', + sensitivityMedium: 'Mittel (ausgewogen)', + sensitivityHigh: 'Hoch (frühe Erkennung, mehr Fehlalarme)', + sensitivityHint: 'Passt die Konfidenz-Schwellwerte an, die Warnungen und Fehler auslösen.', + action: 'Aktion bei erkanntem Fehler', + actionNotify: 'Nur benachrichtigen', + actionPause: 'Druck pausieren', + 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 lädt das Kamerabild per URL. Setze die Externe URL in den allgemeinen Einstellungen, damit der ML-API-Container Bambuddy erreichen kann.', + perPrinterTitle: 'Überwachte Drucker', + perPrinterHint: 'Wähle, welche Drucker vom Erkennungsdienst überwacht werden.', + monitorAll: 'Alle verbundenen Drucker überwachen', + statusTitle: 'Status', + serviceRunning: 'Dienst läuft', + thresholds: 'Niedrig / Hoch-Schwellwerte', + activePrinters: 'Aktive Drucke', + noActivePrints: 'Derzeit laufen keine Drucke.', + historyTitle: 'Letzte Erkennungen', + noHistory: 'Noch keine Erkennungen.', + }, }; diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 4d55a2a96..912974934 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -1309,6 +1309,7 @@ export default { apiKeys: 'API Keys', virtualPrinter: 'Virtual Printer', spoolbuddy: 'SpoolBuddy', + failureDetection: 'Failure Detection', users: 'Authentication', backup: 'Backup', emailAuth: 'Email Authentication', @@ -4853,4 +4854,36 @@ export default { viewIssue: 'View Issue', unexpectedError: 'An unexpected error occurred', }, + failureDetection: { + title: 'AI Failure Detection', + 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).', + test: 'Test', + testSuccess: 'ML API reachable and healthy.', + testFailed: 'Could not reach the ML API.', + sensitivity: 'Sensitivity', + sensitivityLow: 'Low (fewer false positives)', + sensitivityMedium: 'Medium (balanced)', + sensitivityHigh: 'High (detect early, more false positives)', + sensitivityHint: 'Adjusts the confidence thresholds that trigger warnings and failures.', + action: 'Action on detected failure', + actionNotify: 'Notify only', + actionPause: 'Pause print', + 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.', + perPrinterTitle: 'Monitored Printers', + perPrinterHint: 'Choose which printers the detection service watches.', + monitorAll: 'Monitor all connected printers', + statusTitle: 'Status', + serviceRunning: 'Service running', + thresholds: 'Low / High thresholds', + activePrinters: 'Active prints', + noActivePrints: 'No prints currently running.', + historyTitle: 'Recent Detections', + noHistory: 'No detections yet.', + }, }; diff --git a/frontend/src/i18n/locales/fr.ts b/frontend/src/i18n/locales/fr.ts index c881c3014..b2ba60eb5 100644 --- a/frontend/src/i18n/locales/fr.ts +++ b/frontend/src/i18n/locales/fr.ts @@ -1307,6 +1307,7 @@ export default { network: 'Réseau', apiKeys: 'Clés API', virtualPrinter: 'Imprimante virtuelle', + failureDetection: 'Détection d\'échec', users: 'Authentification', backup: 'Sauvegarde', emailAuth: 'Authentification Email', @@ -4799,4 +4800,36 @@ export default { viewIssue: 'Voir l\'issue', unexpectedError: 'Une erreur inattendue est survenue', }, + failureDetection: { + title: 'Détection d\'échec par IA', + 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).', + test: 'Tester', + testSuccess: 'API ML accessible et fonctionnelle.', + testFailed: 'Impossible d\'atteindre l\'API ML.', + sensitivity: 'Sensibilité', + sensitivityLow: 'Basse (moins de faux positifs)', + sensitivityMedium: 'Moyenne (équilibrée)', + sensitivityHigh: 'Haute (détection précoce, plus de faux positifs)', + sensitivityHint: 'Ajuste les seuils de confiance qui déclenchent les avertissements et les échecs.', + action: 'Action sur échec détecté', + actionNotify: 'Notifier uniquement', + actionPause: 'Mettre en pause', + 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 l\'image de la caméra via URL. Définissez l\'URL externe dans les paramètres généraux pour que le conteneur ML API puisse atteindre Bambuddy.', + perPrinterTitle: 'Imprimantes surveillées', + perPrinterHint: 'Choisissez quelles imprimantes le service de détection surveille.', + monitorAll: 'Surveiller toutes les imprimantes connectées', + statusTitle: 'Statut', + serviceRunning: 'Service en cours d\'exécution', + thresholds: 'Seuils bas / haut', + activePrinters: 'Impressions actives', + noActivePrints: 'Aucune impression en cours.', + historyTitle: 'Détections récentes', + noHistory: 'Aucune détection pour le moment.', + }, }; diff --git a/frontend/src/i18n/locales/it.ts b/frontend/src/i18n/locales/it.ts index 00e19390b..b76391765 100644 --- a/frontend/src/i18n/locales/it.ts +++ b/frontend/src/i18n/locales/it.ts @@ -1307,6 +1307,7 @@ export default { network: 'Rete', apiKeys: 'Chiavi API', virtualPrinter: 'Stampante virtuale', + failureDetection: 'Rilevamento guasti', users: 'Utenti', backup: 'Backup', emailAuth: 'Autenticazione Email', @@ -4798,4 +4799,36 @@ export default { viewIssue: 'Vedi issue', unexpectedError: 'Si è verificato un errore imprevisto', }, + failureDetection: { + title: 'Rilevamento guasti con IA', + 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).', + test: 'Prova', + testSuccess: 'API ML raggiungibile e funzionante.', + testFailed: 'Impossibile raggiungere l\'API ML.', + sensitivity: 'Sensibilità', + sensitivityLow: 'Bassa (meno falsi positivi)', + sensitivityMedium: 'Media (bilanciata)', + sensitivityHigh: 'Alta (rilevamento precoce, più falsi positivi)', + sensitivityHint: 'Regola le soglie di confidenza che attivano avvisi e guasti.', + action: 'Azione al guasto rilevato', + actionNotify: 'Solo notifica', + actionPause: 'Metti in pausa', + 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\'immagine della fotocamera tramite URL. Imposta l\'URL esterno nelle impostazioni generali affinché il container ML API possa raggiungere Bambuddy.', + perPrinterTitle: 'Stampanti monitorate', + perPrinterHint: 'Scegli quali stampanti il servizio di rilevamento deve monitorare.', + monitorAll: 'Monitora tutte le stampanti connesse', + statusTitle: 'Stato', + serviceRunning: 'Servizio in esecuzione', + thresholds: 'Soglie bassa / alta', + activePrinters: 'Stampe attive', + noActivePrints: 'Nessuna stampa in corso.', + historyTitle: 'Rilevamenti recenti', + noHistory: 'Nessun rilevamento finora.', + }, }; diff --git a/frontend/src/i18n/locales/ja.ts b/frontend/src/i18n/locales/ja.ts index 77ecda41e..85971852f 100644 --- a/frontend/src/i18n/locales/ja.ts +++ b/frontend/src/i18n/locales/ja.ts @@ -1307,6 +1307,7 @@ export default { apiKeys: 'APIキー', virtualPrinter: '仮想プリンター', spoolbuddy: 'SpoolBuddy', + failureDetection: '失敗検出', users: '認証', backup: 'バックアップ', emailAuth: 'メール認証', @@ -4837,4 +4838,36 @@ export default { viewIssue: 'Issueを表示', unexpectedError: '予期しないエラーが発生しました', }, + failureDetection: { + title: 'AI 失敗検出', + description: 'セルフホストされた Obico ML API で印刷を監視し、検出された失敗に自動的に対応します。', + mlUrl: 'Obico ML API の URL', + mlUrlHint: 'セルフホストした Obico ml_api コンテナのベース URL (例: http://192.168.1.10:3333)。', + test: 'テスト', + testSuccess: 'ML API に接続でき、正常です。', + testFailed: 'ML API に接続できませんでした。', + sensitivity: '感度', + sensitivityLow: '低(誤検出が少ない)', + sensitivityMedium: '中(バランス型)', + sensitivityHigh: '高(早期検出、誤検出が増加)', + sensitivityHint: '警告と失敗をトリガーする信頼度のしきい値を調整します。', + action: '失敗検出時の動作', + actionNotify: '通知のみ', + actionPause: '印刷を一時停止', + actionPauseOff: '一時停止して電源を切る', + pollInterval: 'ポーリング間隔(秒)', + pollIntervalHint: '印刷中に各プリンターをチェックする頻度。最小 5 秒、最大 120 秒。', + externalUrlMissing: '外部 URL が設定されていません。', + externalUrlHint: 'ML API は URL 経由でカメラ画像を取得します。ML API コンテナが Bambuddy に到達できるよう、一般設定で外部 URL を設定してください。', + perPrinterTitle: '監視対象プリンター', + perPrinterHint: '検出サービスが監視するプリンターを選択します。', + monitorAll: '接続されているすべてのプリンターを監視', + statusTitle: 'ステータス', + serviceRunning: 'サービス稼働中', + thresholds: '低 / 高しきい値', + activePrinters: 'アクティブな印刷', + noActivePrints: '現在、実行中の印刷はありません。', + historyTitle: '最近の検出', + noHistory: 'まだ検出はありません。', + }, }; diff --git a/frontend/src/i18n/locales/pt-BR.ts b/frontend/src/i18n/locales/pt-BR.ts index 02952fda9..c5beb9c69 100644 --- a/frontend/src/i18n/locales/pt-BR.ts +++ b/frontend/src/i18n/locales/pt-BR.ts @@ -1307,6 +1307,7 @@ export default { network: 'Rede', apiKeys: 'Chaves API', virtualPrinter: 'Impressora Virtual', + failureDetection: 'Detecção de Falhas', users: 'Autenticação', backup: 'Backup', emailAuth: 'Autenticação por Email', @@ -4798,4 +4799,36 @@ export default { viewIssue: 'Ver issue', unexpectedError: 'Ocorreu um erro inesperado', }, + failureDetection: { + title: 'Detecção de Falhas por IA', + 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).', + test: 'Testar', + testSuccess: 'API ML acessível e operacional.', + testFailed: 'Não foi possível acessar a API ML.', + sensitivity: 'Sensibilidade', + sensitivityLow: 'Baixa (menos falsos positivos)', + sensitivityMedium: 'Média (equilibrada)', + sensitivityHigh: 'Alta (detecção precoce, mais falsos positivos)', + sensitivityHint: 'Ajusta os limiares de confiança que disparam avisos e falhas.', + action: 'Ação em falha detectada', + actionNotify: 'Apenas notificar', + actionPause: 'Pausar impressão', + 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 configurada.', + externalUrlHint: 'A API ML busca a imagem 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.', + perPrinterTitle: 'Impressoras monitoradas', + perPrinterHint: 'Escolha quais impressoras o serviço de detecção monitora.', + monitorAll: 'Monitorar todas as impressoras conectadas', + statusTitle: 'Status', + serviceRunning: 'Serviço em execução', + thresholds: 'Limiares baixo / alto', + activePrinters: 'Impressões ativas', + noActivePrints: 'Nenhuma impressão em andamento.', + historyTitle: 'Detecções recentes', + noHistory: 'Nenhuma detecção ainda.', + }, }; diff --git a/frontend/src/i18n/locales/zh-CN.ts b/frontend/src/i18n/locales/zh-CN.ts index 8052b3b8b..2b82f75e8 100644 --- a/frontend/src/i18n/locales/zh-CN.ts +++ b/frontend/src/i18n/locales/zh-CN.ts @@ -1307,6 +1307,7 @@ export default { network: '网络', apiKeys: 'API 密钥', virtualPrinter: '虚拟打印机', + failureDetection: '故障检测', users: '身份验证', backup: '备份', emailAuth: '邮箱认证', @@ -4797,4 +4798,36 @@ export default { viewIssue: '查看Issue', unexpectedError: '发生了意外错误', }, + failureDetection: { + title: 'AI 故障检测', + description: '通过自托管的 Obico ML API 监控打印,并对检测到的故障自动采取行动。', + mlUrl: 'Obico ML API 地址', + mlUrlHint: '您自托管的 Obico ml_api 容器的基础 URL(例如 http://192.168.1.10:3333)。', + test: '测试', + testSuccess: 'ML API 可访问且正常。', + testFailed: '无法访问 ML API。', + sensitivity: '灵敏度', + sensitivityLow: '低(减少误报)', + sensitivityMedium: '中(平衡)', + sensitivityHigh: '高(更早检测,更多误报)', + sensitivityHint: '调整触发警告和故障的置信度阈值。', + action: '检测到故障时的操作', + actionNotify: '仅通知', + actionPause: '暂停打印', + actionPauseOff: '暂停并切断电源', + pollInterval: '检查间隔(秒)', + pollIntervalHint: '打印过程中每台打印机的检查频率。最小 5 秒,最大 120 秒。', + externalUrlMissing: '未设置外部 URL。', + externalUrlHint: 'ML API 通过 URL 获取相机截图。请在常规设置中设置外部 URL,以便 ML API 容器可以访问 Bambuddy。', + perPrinterTitle: '监控的打印机', + perPrinterHint: '选择检测服务要监视哪些打印机。', + monitorAll: '监控所有已连接的打印机', + statusTitle: '状态', + serviceRunning: '服务运行中', + thresholds: '低 / 高阈值', + activePrinters: '活动打印', + noActivePrints: '当前没有正在进行的打印。', + historyTitle: '最近检测', + noHistory: '暂无检测记录。', + }, }; diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx index 852d5a33e..9750f97f9 100644 --- a/frontend/src/pages/SettingsPage.tsx +++ b/frontend/src/pages/SettingsPage.tsx @@ -1,5 +1,5 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { Loader2, Plus, Plug, AlertTriangle, RotateCcw, Bell, Download, RefreshCw, ExternalLink, Globe, Droplets, Thermometer, FileText, Edit2, Send, CheckCircle, XCircle, History, Trash2, Zap, TrendingUp, Calendar, DollarSign, Power, PowerOff, Key, Copy, Database, X, Shield, Printer, Cylinder, Wifi, Home, Video, Users, Lock, Unlock, ChevronDown, Save, Mail, Flame, Layers, ListOrdered, Code, Search, Scale, Settings as SettingsIcon } from 'lucide-react'; +import { Loader2, Plus, Plug, AlertTriangle, RotateCcw, Bell, Download, RefreshCw, ExternalLink, Globe, Droplets, Thermometer, FileText, Edit2, Send, CheckCircle, XCircle, History, Trash2, Zap, TrendingUp, Calendar, DollarSign, Power, PowerOff, Key, Copy, Database, X, Shield, Printer, Cylinder, Wifi, Home, Video, Users, Lock, Unlock, ChevronDown, Save, Mail, Flame, Layers, ListOrdered, Code, Search, Scale, Settings as SettingsIcon, ScanEye } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { useNavigate, useSearchParams } from 'react-router-dom'; import { api } from '../api/client'; @@ -25,6 +25,7 @@ import { ExternalLinksSettings } from '../components/ExternalLinksSettings'; import { VirtualPrinterList } from '../components/VirtualPrinterList'; import { SpoolBuddySettings } from '../components/SpoolBuddySettings'; import { GitHubBackupSettings } from '../components/GitHubBackupSettings'; +import { FailureDetectionSettings } from '../components/FailureDetectionSettings'; import { EmailSettings } from '../components/EmailSettings'; import { LDAPSettings } from '../components/LDAPSettings'; import { APIBrowser } from '../components/APIBrowser'; @@ -37,7 +38,7 @@ import { useTheme, type ThemeStyle, type DarkBackground, type LightBackground, t import { useState, useEffect, useRef, useCallback } from 'react'; import { Palette } from 'lucide-react'; -const validTabs = ['general', 'plugs', 'notifications', 'queue', 'filament', 'network', 'apikeys', 'virtual-printer', 'spoolbuddy', 'users', 'backup'] as const; +const validTabs = ['general', 'plugs', 'notifications', 'queue', 'filament', 'network', 'apikeys', 'virtual-printer', 'spoolbuddy', 'failure-detection', 'users', 'backup'] as const; type TabType = typeof validTabs[number]; type UsersSubTab = 'users' | 'email' | 'ldap'; @@ -366,6 +367,14 @@ export function SettingsPage() { const spoolbuddyDeviceCount = spoolbuddyDevices?.length ?? 0; const spoolbuddyAnyOnline = spoolbuddyDevices?.some((d) => d.online) ?? false; + // Obico failure-detection service status for tab indicator + const { data: obicoStatus } = useQuery({ + queryKey: ['obico-status'], + queryFn: api.getObicoStatus, + refetchInterval: 15000, + }); + const obicoActive = !!(obicoStatus?.is_running && obicoStatus?.enabled); + const { data: ffmpegStatus } = useQuery({ queryKey: ['ffmpeg-status'], queryFn: api.checkFfmpeg, @@ -1096,7 +1105,7 @@ export function SettingsPage() { >

{entry.label}

- {t(`settings.tabs.${entry.tab === 'virtual-printer' ? 'virtualPrinter' : entry.tab}`)} + {t(`settings.tabs.${entry.tab === 'virtual-printer' ? 'virtualPrinter' : entry.tab === 'failure-detection' ? 'failureDetection' : entry.tab}`)} {entry.subTab ? ` › ${t(`settings.tabs.${entry.subTab}`, entry.subTab)}` : ''}

@@ -1236,6 +1245,18 @@ export function SettingsPage() { )} +