From 44e48e11cf6fc0ccf0959c93651ce7bb2a1a420f Mon Sep 17 00:00:00 2001 From: ichwars Date: Mon, 7 Sep 2026 08:16:25 +0200 Subject: [PATCH 1/3] fix(obico): distinguish missing verdicts Closes #141 --- backend/app/api/routes/obico.py | 37 ++++- backend/app/services/obico_detection.py | 53 ++++--- .../integration/test_issue_141_obico_api.py | 89 +++++++++++ .../unit/test_issue_141_obico_verdict.py | 140 ++++++++++++++++++ .../components/AiDetectionBadge.test.tsx | 63 ++++++++ .../FailureDetectionSettings.test.tsx | 53 ++++++- frontend/src/__tests__/mocks/handlers.ts | 3 + frontend/src/api/client/backups-slicer.ts | 6 +- .../api/client/types/notifications-backups.ts | 16 +- frontend/src/components/AiDetectionBadge.tsx | 71 +++++++++ frontend/src/components/AiDetectionModal.tsx | 112 ++++++++++++++ .../components/FailureDetectionSettings.tsx | 61 +++++--- frontend/src/i18n/locales/de.ts | 20 +++ frontend/src/i18n/locales/en.ts | 20 +++ frontend/src/i18n/locales/es.ts | 20 +++ frontend/src/i18n/locales/fr.ts | 20 +++ frontend/src/i18n/locales/it.ts | 20 +++ frontend/src/i18n/locales/ja.ts | 20 +++ frontend/src/i18n/locales/ko.ts | 20 +++ frontend/src/i18n/locales/pt-BR.ts | 20 +++ frontend/src/i18n/locales/tr.ts | 20 +++ frontend/src/i18n/locales/zh-CN.ts | 20 +++ frontend/src/i18n/locales/zh-TW.ts | 20 +++ frontend/src/pages/printers/PrinterCard.tsx | 8 +- frontend/src/utils/aiDetection.ts | 25 ++++ tools/check_source_size_budget.py | 2 +- 26 files changed, 903 insertions(+), 56 deletions(-) create mode 100644 backend/tests/integration/test_issue_141_obico_api.py create mode 100644 backend/tests/unit/test_issue_141_obico_verdict.py create mode 100644 frontend/src/__tests__/components/AiDetectionBadge.test.tsx create mode 100644 frontend/src/components/AiDetectionBadge.tsx create mode 100644 frontend/src/components/AiDetectionModal.tsx create mode 100644 frontend/src/utils/aiDetection.ts diff --git a/backend/app/api/routes/obico.py b/backend/app/api/routes/obico.py index 7b588124f6..6751b9224b 100644 --- a/backend/app/api/routes/obico.py +++ b/backend/app/api/routes/obico.py @@ -16,7 +16,7 @@ class TestConnectionRequest(BaseModel): - url: str + url: str | None = None token: str | None = None @@ -38,19 +38,42 @@ async def get_status( } +@router.get("/printer-status") +async def get_printer_status( + user: User | None = RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ), +): + """Return live card states without exposing Obico configuration.""" + settings = await obico_detection_service._load_settings() + enabled_printers = settings["enabled_printers"] + can_see_error = user is None or user.has_permission(Permission.SETTINGS_READ.value) + per_printer = obico_detection_service.get_per_printer() + if not can_see_error: + per_printer = {printer_id: {**entry, "error": None} for printer_id, entry in per_printer.items()} + return { + "enabled": settings["enabled"], + "monitored_printers": sorted(enabled_printers) if enabled_printers is not None else None, + "per_printer": per_printer, + "last_error": obico_detection_service._last_error if can_see_error else None, + } + + @router.post("/test-connection") async def test_connection( req: TestConnectionRequest, _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE), ): - """Ping the Obico ML API health endpoint and check token acceptance.""" - if not req.url: - return {"ok": False, "status_code": None, "body": None, "error": "URL is empty", "auth_ok": None} + """Ping an explicit configuration, or the saved one when values are omitted.""" + url = req.url token = req.token - if token is None: + if url is None or token is None: settings = await obico_detection_service._load_settings() - token = settings.get("ml_token") or "" - return await obico_detection_service.test_connection(req.url, token) + if url is None: + url = settings.get("ml_url") or "" + if token is None: + token = settings.get("ml_token") or "" + if not url: + return {"ok": False, "status_code": None, "body": None, "error": "URL is empty", "auth_ok": None} + return await obico_detection_service.test_connection(url, token) @router.get("/cached-frame/{nonce}") diff --git a/backend/app/services/obico_detection.py b/backend/app/services/obico_detection.py index cc14b59b7b..15cef75b72 100644 --- a/backend/app/services/obico_detection.py +++ b/backend/app/services/obico_detection.py @@ -90,6 +90,8 @@ def __init__(self): self._state_keys: dict[int, str] = {} # printer_id -> last classification ("safe"/"warning"/"failure") self._last_class: dict[int, str] = {} + # printer_id -> why the latest poll produced no verdict + self._errors: 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) @@ -192,6 +194,8 @@ async def _poll_once(self, settings: dict): self._states.pop(printer_id, None) self._state_keys.pop(printer_id, None) self._action_fired.pop(printer_id, None) + self._last_class.pop(printer_id, None) + self._errors.pop(printer_id, None) continue await self._check_printer(printer_id, status, settings) @@ -247,6 +251,12 @@ async def _capture_frame(self, printer_id: int) -> bytes | None: timeout=SNAPSHOT_CAPTURE_TIMEOUT, ) + def _no_verdict(self, printer_id: int, reason: str) -> None: + """Record a failed poll without reusing an earlier successful verdict.""" + self._errors[printer_id] = reason + self._last_error = reason + logger.warning(reason) + 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}" @@ -254,6 +264,8 @@ async def _check_printer(self, printer_id: int, status, settings: dict): self._states[printer_id] = PrintState() self._state_keys[printer_id] = key self._action_fired[printer_id] = False + self._last_class.pop(printer_id, None) + self._errors.pop(printer_id, None) # Capture locally first, then hand Obico a nonce URL that returns the # cached bytes instantly. Obico's ML API is GET-only (/p/?img=URL) with a @@ -261,17 +273,16 @@ async def _check_printer(self, printer_id: int, status, settings: dict): # keyframe wait. frame = await self._capture_frame(printer_id) if not frame: - self._last_error = f"Failed to capture snapshot for printer {printer_id}" - logger.warning(self._last_error) + 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: - self._last_error = ( - "external_url setting is empty — Obico's ML API needs a reachable URL to fetch the snapshot from. " - "Set Settings → General → External URL." + self._no_verdict( + printer_id, + "external_url (External URL) setting is empty — Obico's ML API needs a reachable URL to fetch the snapshot from. " + "Set Settings → General → External URL.", ) - logger.warning(self._last_error) return nonce = await stash_frame(frame) @@ -286,19 +297,18 @@ async def _check_printer(self, printer_id: int, status, settings: dict): headers=auth_headers(settings.get("ml_token")), ) if resp.status_code == 401: - self._last_error = ( + reason = ( "Obico ML API rejected the token (401). Set Settings > Failure Detection > " "ML API Token to the ML_API_TOKEN the server runs with, or clear ML_API_TOKEN " "on the server." ) - logger.warning("%s (printer %s)", self._last_error, printer_id) + self._no_verdict(printer_id, reason) return resp.raise_for_status() payload = resp.json() except Exception as e: detail = str(e) or type(e).__name__ - self._last_error = f"ML API call failed for printer {printer_id}: {detail}" - logger.warning(self._last_error) + self._no_verdict(printer_id, f"ML API call failed for printer {printer_id}: {detail}") return detections = payload.get("detections", []) if isinstance(payload, dict) else [] @@ -310,6 +320,7 @@ async def _check_printer(self, printer_id: int, status, settings: dict): # A successful capture + ML call clears any transient error from previous # polls (typical case: cold-start RTSP timeout on first frame after startup, # followed by healthy polls that otherwise leave the banner stuck in the UI). + self._errors.pop(printer_id, None) self._last_error = None # Log every non-safe sample — safe samples would flood history @@ -348,6 +359,19 @@ async def _dispatch_action(self, printer_id: int, action: str, task_name: str, s # ---- queries ---- + def get_per_printer(self) -> dict: + """Return honest live states for printers with an active monitored print.""" + result = {} + for printer_id, state in self._states.items(): + error = self._errors.get(printer_id) + result[printer_id] = { + "class": "error" if error else self._last_class.get(printer_id, "unknown"), + "frame_count": state.frame_count, + "score": round(state.ewm_mean, 4), + "error": error, + } + return result + def get_status(self, sensitivity: str = "medium") -> dict: # Report the thresholds for the configured sensitivity, not a hardcoded # "medium" — otherwise the Status panel always shows the medium row @@ -357,14 +381,7 @@ def get_status(self, sensitivity: str = "medium") -> dict: 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() - }, + "per_printer": self.get_per_printer(), "thresholds": {"low": low, "high": high}, "history": list(self._history), } diff --git a/backend/tests/integration/test_issue_141_obico_api.py b/backend/tests/integration/test_issue_141_obico_api.py new file mode 100644 index 0000000000..462970d8e6 --- /dev/null +++ b/backend/tests/integration/test_issue_141_obico_api.py @@ -0,0 +1,89 @@ +"""API regressions for Obico printer verdicts and permission redaction (#141).""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from httpx import AsyncClient + +from backend.app.api.routes.obico import ( + TestConnectionRequest as ObicoTestConnectionRequest, + get_printer_status, + test_connection as obico_test_connection, +) +from backend.app.services.obico_detection import obico_detection_service +from backend.app.services.obico_smoothing import PrintState + + +@pytest.fixture(autouse=True) +def clear_detection_state(): + obico_detection_service._states.clear() + obico_detection_service._last_class.clear() + obico_detection_service._errors.clear() + obico_detection_service._last_error = None + yield + obico_detection_service._states.clear() + obico_detection_service._last_class.clear() + obico_detection_service._errors.clear() + obico_detection_service._last_error = None + + +@pytest.mark.asyncio +@pytest.mark.integration +async def test_printer_status_exposes_error_class_and_reason_without_reporting_safe( + async_client: AsyncClient, +): + obico_detection_service._states[1] = PrintState() + obico_detection_service._errors[1] = "Obico ML API rejected the token (401)." + + response = await async_client.get("/api/v1/obico/printer-status") + + assert response.status_code == 200 + entry = response.json()["per_printer"]["1"] + assert entry["class"] == "error" + assert entry["error"] == "Obico ML API rejected the token (401)." + + +@pytest.mark.asyncio +async def test_printer_reader_without_settings_permission_gets_class_but_not_internal_reason(): + obico_detection_service._states[1] = PrintState() + obico_detection_service._errors[1] = "ML API http://192.168.8.9:3333 refused" + obico_detection_service._last_error = obico_detection_service._errors[1] + user = MagicMock() + user.has_permission.return_value = False + loaded = {"enabled": True, "enabled_printers": None} + + with patch.object( + obico_detection_service, + "_load_settings", + new=AsyncMock(return_value=loaded), + ): + data = await get_printer_status(user=user) + + assert data["per_printer"][1]["class"] == "error" + assert data["per_printer"][1]["error"] is None + assert data["last_error"] is None + assert "192.168.8.9" not in str(data) + + +@pytest.mark.asyncio +async def test_connection_without_payload_values_checks_saved_configuration(): + loaded = { + "ml_url": "http://saved-obico:3333", + "ml_token": "saved-token", + } + with ( + patch.object( + obico_detection_service, + "_load_settings", + new=AsyncMock(return_value=loaded), + ), + patch.object( + obico_detection_service, + "test_connection", + new=AsyncMock(return_value={"ok": True}), + ) as probe, + ): + result = await obico_test_connection(ObicoTestConnectionRequest()) + + assert result == {"ok": True} + probe.assert_awaited_once_with("http://saved-obico:3333", "saved-token") diff --git a/backend/tests/unit/test_issue_141_obico_verdict.py b/backend/tests/unit/test_issue_141_obico_verdict.py new file mode 100644 index 0000000000..1df4a89883 --- /dev/null +++ b/backend/tests/unit/test_issue_141_obico_verdict.py @@ -0,0 +1,140 @@ +"""Regression coverage for honest Obico verdict states (issue #141).""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from backend.app.services.obico_detection import ObicoDetectionService +from backend.app.services.obico_smoothing import PrintState + +SETTINGS = { + "enabled": True, + "ml_url": "http://obico:3333", + "ml_token": "wrong-token", + "sensitivity": "medium", + "action": "notify", + "poll_interval": 10, + "enabled_printers": None, + "external_url": "http://printops:8000", +} + + +def _status(state: str = "RUNNING") -> MagicMock: + return MagicMock(state=state, task_name="job", subtask_name="") + + +def _client(**get_kwargs) -> MagicMock: + client = MagicMock() + client.get = AsyncMock(**get_kwargs) + client.__aenter__ = AsyncMock(return_value=client) + client.__aexit__ = AsyncMock(return_value=False) + return client + + +def test_pending_first_inference_is_unknown_not_safe(): + service = ObicoDetectionService() + service._states[1] = PrintState() + service._state_keys[1] = "job" + + entry = service.get_per_printer()[1] + + assert entry["class"] == "unknown" + assert entry["error"] is None + + +@pytest.mark.asyncio +async def test_capture_failure_replaces_previous_safe_verdict_with_error(): + service = ObicoDetectionService() + service._last_class[1] = "safe" + with patch.object(service, "_capture_frame", new=AsyncMock(return_value=None)): + await service._check_printer(1, _status(), SETTINGS) + + entry = service.get_per_printer()[1] + + assert entry["class"] == "error" + assert "capture" in entry["error"].lower() + assert entry["frame_count"] == 0 + + +@pytest.mark.asyncio +async def test_new_print_clears_previous_verdict_before_capture_completes(): + service = ObicoDetectionService() + service._states[1] = PrintState() + service._state_keys[1] = "previous-job" + service._last_class[1] = "safe" + + async def observe_pending_state(_printer_id: int): + assert service.get_per_printer()[1]["class"] == "unknown" + return None + + with patch.object(service, "_capture_frame", side_effect=observe_pending_state): + await service._check_printer(1, _status(), SETTINGS) + + assert service.get_per_printer()[1]["class"] == "error" + + +@pytest.mark.asyncio +async def test_rejected_token_reports_actionable_per_printer_error(): + service = ObicoDetectionService() + response = MagicMock(status_code=401) + with ( + patch( + "backend.app.services.obico_detection.httpx.AsyncClient", + return_value=_client(return_value=response), + ), + patch.object(service, "_capture_frame", new=AsyncMock(return_value=b"jpeg")), + ): + await service._check_printer(1, _status(), SETTINGS) + + entry = service.get_per_printer()[1] + + assert entry["class"] == "error" + assert "ML API Token" in entry["error"] + + +@pytest.mark.asyncio +async def test_successful_inference_recovers_from_error_to_real_safe_verdict(): + service = ObicoDetectionService() + with patch.object(service, "_capture_frame", new=AsyncMock(return_value=None)): + await service._check_printer(1, _status(), SETTINGS) + assert service.get_per_printer()[1]["class"] == "error" + + response = MagicMock(status_code=200) + response.json.return_value = {"detections": []} + response.raise_for_status = MagicMock() + with ( + patch( + "backend.app.services.obico_detection.httpx.AsyncClient", + return_value=_client(return_value=response), + ), + patch.object(service, "_capture_frame", new=AsyncMock(return_value=b"jpeg")), + ): + await service._check_printer(1, _status(), SETTINGS) + + entry = service.get_per_printer()[1] + + assert entry["class"] == "safe" + assert entry["error"] is None + assert entry["frame_count"] == 1 + + +@pytest.mark.asyncio +async def test_print_end_clears_verdict_and_error_state(): + service = ObicoDetectionService() + service._states[1] = PrintState() + service._state_keys[1] = "job" + service._last_class[1] = "safe" + service._errors[1] = "camera failed" + manager = MagicMock() + manager.get_all_statuses.return_value = {1: _status("IDLE")} + manager.is_connected.return_value = True + + with patch.dict( + "sys.modules", + {"backend.app.services.printer_manager": MagicMock(printer_manager=manager)}, + ): + await service._poll_once(SETTINGS) + + assert service.get_per_printer() == {} + assert service._last_class == {} + assert service._errors == {} diff --git a/frontend/src/__tests__/components/AiDetectionBadge.test.tsx b/frontend/src/__tests__/components/AiDetectionBadge.test.tsx new file mode 100644 index 0000000000..f19b9b9afc --- /dev/null +++ b/frontend/src/__tests__/components/AiDetectionBadge.test.tsx @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest'; +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { http, HttpResponse } from 'msw'; +import { AiDetectionBadge } from '../../components/AiDetectionBadge'; +import { server } from '../mocks/server'; +import { render } from '../utils'; + +function respondWith(entry: Record) { + server.use( + http.get('/api/v1/obico/printer-status', () => + HttpResponse.json({ + enabled: true, + monitored_printers: [1], + per_printer: { '1': entry }, + last_error: null, + }), + ), + ); +} + +describe('AiDetectionBadge issue #141', () => { + it('shows Not checking and omits a fabricated score after a failed poll', async () => { + respondWith({ + class: 'error', + frame_count: 0, + score: 0, + error: 'Obico ML API rejected the token (401).', + }); + render(); + + const badge = await screen.findByRole('button', { name: 'Not checking' }); + expect(badge).toHaveAttribute( + 'title', + 'AI Failure Detection is not checking this print: Obico ML API rejected the token (401). - click for details', + ); + expect(screen.queryByText('Safe')).not.toBeInTheDocument(); + + await userEvent.click(badge); + expect(await screen.findByText('AI Failure Detection - X1 Carbon')).toBeInTheDocument(); + expect(screen.getByText('Obico ML API rejected the token (401).')).toBeInTheDocument(); + expect(screen.queryByText('0.000')).not.toBeInTheDocument(); + expect(screen.queryByText('Frames analyzed')).not.toBeInTheDocument(); + }); + + it('shows Starting before the first inference and never falls back to Safe', async () => { + respondWith({ class: 'unknown', frame_count: 0, score: 0, error: null }); + render(); + + expect(await screen.findByRole('button', { name: 'Starting' })).toBeInTheDocument(); + expect(screen.queryByText('Safe')).not.toBeInTheDocument(); + }); + + it('shows Safe only for an actual inference verdict', async () => { + respondWith({ class: 'safe', frame_count: 12, score: 0, error: null }); + render(); + + expect(await screen.findByRole('button', { name: 'Safe' })).toHaveAttribute( + 'title', + 'AI Failure Detection: Safe (score 0.000) - click for details', + ); + }); +}); diff --git a/frontend/src/__tests__/components/FailureDetectionSettings.test.tsx b/frontend/src/__tests__/components/FailureDetectionSettings.test.tsx index cf6cab359b..16b93a9662 100644 --- a/frontend/src/__tests__/components/FailureDetectionSettings.test.tsx +++ b/frontend/src/__tests__/components/FailureDetectionSettings.test.tsx @@ -69,8 +69,7 @@ describe('FailureDetectionSettings', () => { ), 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'); + expect(await request.json()).toEqual({}); return HttpResponse.json({ ok: true, status_code: 200, body: 'ok', error: null }); }), ); @@ -83,6 +82,33 @@ describe('FailureDetectionSettings', () => { expect(await screen.findByText(/ML API reachable/i)).toBeInTheDocument(); }); + it('saves pending form changes before testing the persisted configuration', async () => { + const calls: string[] = []; + server.use( + http.get('/api/v1/settings/', () => + HttpResponse.json({ ...baseSettings, obico_enabled: true, obico_ml_url: 'http://old:3333' }), + ), + http.put('/api/v1/settings/', async ({ request }) => { + calls.push('save'); + return HttpResponse.json({ ...baseSettings, ...((await request.json()) as object) }); + }), + http.post('/api/v1/obico/test-connection', async ({ request }) => { + calls.push('test'); + expect(await request.json()).toEqual({}); + return HttpResponse.json({ ok: true, status_code: 200, body: 'ok', error: null }); + }), + ); + render(); + const user = userEvent.setup(); + const url = await screen.findByDisplayValue('http://old:3333'); + await user.clear(url); + await user.type(url, 'http://saved:3333'); + + await user.click(screen.getByRole('button', { name: /test/i })); + + await waitFor(() => expect(calls).toEqual(['save', 'test'])); + }); + it('shows failure class history entries with red styling', async () => { server.use( http.get('/api/v1/obico/status', () => @@ -106,4 +132,27 @@ describe('FailureDetectionSettings', () => { // Match the history row's score-and-class text, which looks like "failure 0.850" expect(await screen.findByText(/failure\s+0\.850/)).toBeInTheDocument(); }); + + it('shows a per-printer error without a score that was never produced', async () => { + server.use( + http.get('/api/v1/obico/status', () => + HttpResponse.json({ + ...baseStatus, + per_printer: { + '1': { + class: 'error', + frame_count: 0, + score: 0, + error: 'Obico ML API rejected the token (401).', + }, + }, + }), + ), + ); + render(); + + expect(await screen.findByText('Not checking')).toBeInTheDocument(); + expect(screen.getByText('Obico ML API rejected the token (401).')).toBeInTheDocument(); + expect(screen.queryByText(/0\.000/)).not.toBeInTheDocument(); + }); }); diff --git a/frontend/src/__tests__/mocks/handlers.ts b/frontend/src/__tests__/mocks/handlers.ts index 21a5cff4e4..d9b803cfc7 100644 --- a/frontend/src/__tests__/mocks/handlers.ts +++ b/frontend/src/__tests__/mocks/handlers.ts @@ -535,6 +535,9 @@ export const handlers = [ external_url_configured: false, }) ), + http.get('/api/v1/obico/printer-status', () => + HttpResponse.json({ enabled: false, monitored_printers: null, per_printer: {}, last_error: null }) + ), http.get('/api/v1/printers/:id/current-print-user', () => HttpResponse.json(null)), http.get('/api/v1/settings/check-ffmpeg', () => HttpResponse.json({ available: false, version: null }) diff --git a/frontend/src/api/client/backups-slicer.ts b/frontend/src/api/client/backups-slicer.ts index e542ef4d9c..456126b87f 100644 --- a/frontend/src/api/client/backups-slicer.ts +++ b/frontend/src/api/client/backups-slicer.ts @@ -18,6 +18,7 @@ import type { LocalPresetDetail, LocalPresetsResponse, ObicoStatus, + ObicoPrinterStatus, ObicoTestConnection, PipelineEligibilityReport, PipelineRun, @@ -118,7 +119,10 @@ export const backupsSlicerMethods = { getObicoStatus: () => request('/obico/status'), - testObicoConnection: (url: string, token?: string | null) => + getObicoPrinterStatus: () => + request('/obico/printer-status'), + + testObicoConnection: (url?: string | null, token?: string | null) => request('/obico/test-connection', { method: 'POST', body: JSON.stringify({ url, token }), diff --git a/frontend/src/api/client/types/notifications-backups.ts b/frontend/src/api/client/types/notifications-backups.ts index 77246e89d6..0245bdab84 100644 --- a/frontend/src/api/client/types/notifications-backups.ts +++ b/frontend/src/api/client/types/notifications-backups.ts @@ -335,10 +335,17 @@ export interface ObicoDetectionEvent { detections: number; } +export interface ObicoPrinterDetection { + class: string; + frame_count: number; + score: number; + error: string | null; +} + export interface ObicoStatus { is_running: boolean; last_error: string | null; - per_printer: Record; + per_printer: Record; thresholds: { low: number; high: number }; history: ObicoDetectionEvent[]; enabled: boolean; @@ -349,6 +356,13 @@ export interface ObicoStatus { external_url_configured: boolean; } +export interface ObicoPrinterStatus { + enabled: boolean; + monitored_printers: number[] | null; + per_printer: Record; + last_error: string | null; +} + export interface ObicoTestConnection { ok: boolean; status_code: number | null; diff --git a/frontend/src/components/AiDetectionBadge.tsx b/frontend/src/components/AiDetectionBadge.tsx new file mode 100644 index 0000000000..08b8967548 --- /dev/null +++ b/frontend/src/components/AiDetectionBadge.tsx @@ -0,0 +1,71 @@ +import { useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { EyeOff, ScanEye } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { api } from '../api/client'; +import { aiDetectionClass } from '../utils/aiDetection'; +import { AiDetectionModal } from './AiDetectionModal'; + +type Props = { + printerId: number; + printerName: string; +}; + +export function AiDetectionBadge({ printerId, printerName }: Props) { + const { t } = useTranslation(); + const [showDetails, setShowDetails] = useState(false); + const { data } = useQuery({ + queryKey: ['obico-printer-status'], + queryFn: api.getObicoPrinterStatus, + refetchInterval: 10000, + }); + const monitored = + data?.enabled && + (data.monitored_printers === null || data.monitored_printers.includes(printerId)); + if (!monitored) return null; + + const detection = data.per_printer[String(printerId)]; + const classification = aiDetectionClass(detection); + const colorClass = { + failure: 'bg-status-error/20 text-status-error', + warning: 'bg-status-warning/20 text-status-warning', + safe: 'bg-status-ok/20 text-status-ok', + error: 'bg-amber-500/20 text-amber-600 dark:text-amber-400', + unknown: 'bg-bambu-dark-tertiary text-bambu-gray', + idle: 'bg-bambu-dark-tertiary text-bambu-gray', + }[classification]; + const title = + classification === 'error' + ? t('printers.aiDetection.tooltipError', { + reason: detection?.error ?? t('printers.aiDetection.error'), + }) + : classification === 'unknown' + ? t('printers.aiDetection.tooltipUnknown') + : detection + ? t('printers.aiDetection.tooltip', { + status: t(`printers.aiDetection.${classification}`), + score: detection.score.toFixed(3), + }) + : t('printers.aiDetection.tooltipIdle'); + const Icon = classification === 'error' ? EyeOff : ScanEye; + + return ( + <> + + {showDetails && ( + setShowDetails(false)} + /> + )} + + ); +} diff --git a/frontend/src/components/AiDetectionModal.tsx b/frontend/src/components/AiDetectionModal.tsx new file mode 100644 index 0000000000..d2ca8db69b --- /dev/null +++ b/frontend/src/components/AiDetectionModal.tsx @@ -0,0 +1,112 @@ +import { useEffect } from 'react'; +import { AlertCircle, ScanEye, Settings, X } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { useNavigate } from 'react-router'; +import { useAuth } from '../contexts/AuthContext'; +import { aiDetectionClass, hasAiVerdict, type AiDetection } from '../utils/aiDetection'; + +type Props = { + printerName: string; + detection?: AiDetection; + onClose: () => void; +}; + +export function AiDetectionModal({ printerName, detection, onClose }: Props) { + const { t } = useTranslation(); + const navigate = useNavigate(); + const { hasPermission } = useAuth(); + const classification = aiDetectionClass(detection); + const reason = detection?.error; + const statusColor = { + failure: 'text-status-error', + warning: 'text-status-warning', + safe: 'text-status-ok', + error: 'text-amber-600 dark:text-amber-400', + unknown: 'text-bambu-gray', + idle: 'text-bambu-gray', + }[classification]; + + useEffect(() => { + const handleEscape = (event: KeyboardEvent) => { + if (event.key === 'Escape') onClose(); + }; + window.addEventListener('keydown', handleEscape); + return () => window.removeEventListener('keydown', handleEscape); + }, [onClose]); + + return ( +
+
event.stopPropagation()} + > +
+
+ +

+ {t('printers.aiDetection.modalTitle', { name: printerName })} +

+
+ +
+ +
+
+
+ {t('printers.aiDetection.currentStatus')} + + {t(`printers.aiDetection.${classification}`)} + +
+ {detection && hasAiVerdict(classification) && ( + <> +
+ {t('printers.aiDetection.score')} + {detection.score.toFixed(3)} +
+
+ {t('printers.aiDetection.framesAnalyzed')} + {detection.frame_count} +
+ + )} + {classification === 'error' && ( +

{t('printers.aiDetection.errorHint')}

+ )} + {!detection &&

{t('printers.aiDetection.idleHint')}

} +
+ + {reason && ( +
+ +
+
+ {t('printers.aiDetection.lastError')} +
+

{reason}

+
+
+ )} +
+ + {hasPermission('settings:read') && ( +
+ +
+ )} +
+
+ ); +} diff --git a/frontend/src/components/FailureDetectionSettings.tsx b/frontend/src/components/FailureDetectionSettings.tsx index a573efab2c..fb689f140e 100644 --- a/frontend/src/components/FailureDetectionSettings.tsx +++ b/frontend/src/components/FailureDetectionSettings.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useMemo, useRef } 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'; @@ -8,6 +8,7 @@ import { Button } from './Button'; import { Checkbox, LegacySelect, NumberField, TextField } from './ui'; import { Toggle } from './Toggle'; import { useToast } from '../contexts/ToastContext'; +import { aiDetectionClass, hasAiVerdict } from '../utils/aiDetection'; type TestResult = { ok: boolean; message: string } | null; @@ -25,6 +26,7 @@ export function FailureDetectionSettings() { const [enabledPrinters, setEnabledPrinters] = useState(null); // null = all const [testResult, setTestResult] = useState(null); const [initialized, setInitialized] = useState(false); + const autoSaveTimer = useRef | null>(null); const { data: settings } = useQuery({ queryKey: ['settings'], @@ -79,27 +81,37 @@ export function FailureDetectionSettings() { }, }); - // Auto-save on change (debounced) - useEffect(() => { - if (!initialized || !settings) return; - const changed = + const hasUnsavedChanges = useMemo(() => { + if (!initialized || !settings) return false; + return ( settings.obico_enabled !== enabled || settings.obico_ml_url !== mlUrl || - settings.obico_ml_token !== mlToken || + (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)); - if (!changed) return; - const id = setTimeout(() => saveMutation.mutate(), 500); - return () => clearTimeout(id); + settings.obico_enabled_printers !== (enabledPrinters === null ? '' : JSON.stringify(enabledPrinters)) + ); + }, [settings, initialized, enabled, mlUrl, mlToken, sensitivity, action, pollInterval, enabledPrinters]); + + // Auto-save on change (debounced) + useEffect(() => { + if (!hasUnsavedChanges) return; + autoSaveTimer.current = setTimeout(() => saveMutation.mutate(), 500); + return () => { + if (autoSaveTimer.current) clearTimeout(autoSaveTimer.current); + }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [enabled, mlUrl, mlToken, sensitivity, action, pollInterval, enabledPrinters, initialized]); + }, [hasUnsavedChanges, enabled, mlUrl, mlToken, sensitivity, action, pollInterval, enabledPrinters]); const handleTest = async () => { setTestResult(null); try { - const res = await api.testObicoConnection(mlUrl, mlToken); + if (hasUnsavedChanges) { + if (autoSaveTimer.current) clearTimeout(autoSaveTimer.current); + await saveMutation.mutateAsync(); + } + const res = await api.testObicoConnection(); if (res.ok) { setTestResult({ ok: true, @@ -329,18 +341,27 @@ export function FailureDetectionSettings() {
{Object.entries(status.per_printer).map(([pid, info]) => { const printer = printers?.find((p) => String(p.id) === pid); + const classification = aiDetectionClass(info); const colorClass = - info.class === 'failure' + classification === 'failure' ? 'text-red-700 dark:text-red-400' - : info.class === 'warning' + : classification === 'warning' ? 'text-amber-700 dark:text-amber-400' - : 'text-green-700 dark:text-green-400'; + : classification === 'safe' + ? 'text-green-700 dark:text-green-400' + : 'text-bambu-gray'; return ( -
- {printer?.name ?? `Printer ${pid}`} - - {info.class} ({info.score.toFixed(3)}, {info.frame_count}f) - +
+
+ {printer?.name ?? `Printer ${pid}`} + + {t(`printers.aiDetection.${classification}`)} + {hasAiVerdict(classification) && ` (${info.score.toFixed(3)}, ${info.frame_count}f)`} + +
+ {info.error && ( +
{info.error}
+ )}
); })} diff --git a/frontend/src/i18n/locales/de.ts b/frontend/src/i18n/locales/de.ts index 56be29c929..04925f20ab 100644 --- a/frontend/src/i18n/locales/de.ts +++ b/frontend/src/i18n/locales/de.ts @@ -731,6 +731,26 @@ export default { }, // HMS errors clickToViewHmsErrors: 'Klicken, um HMS-Fehler anzuzeigen', + aiDetection: { + safe: 'Sicher', + warning: 'Warnung', + failure: 'Fehldruck', + idle: 'Bereit', + error: 'Prüft nicht', + unknown: 'Startet', + tooltipError: 'KI-Fehlererkennung prüft diesen Druck nicht: {{reason}} - klicken für Details', + tooltipUnknown: 'KI-Fehlererkennung: warte auf das erste Ergebnis - klicken für Details', + errorHint: 'Dieser Druck wird nicht geprüft. Die Erkennung läuft automatisch weiter, sobald das Problem unten behoben ist.', + tooltip: 'KI-Fehlererkennung: {{status}} (Score {{score}}) - klicken für Details', + tooltipIdle: 'KI-Fehlererkennung aktiviert - Überwachung startet mit dem nächsten Druck - klicken für Details', + modalTitle: 'KI-Fehlererkennung - {{name}}', + currentStatus: 'Status', + score: 'KI-Wert', + framesAnalyzed: 'Analysierte Bilder', + idleHint: 'Derzeit wird kein Druck überwacht. Die Überwachung startet automatisch mit dem nächsten Druck.', + lastError: 'Letzter Fehler', + openSettings: 'Einstellungen öffnen', + }, estimatedCompletion: 'Geschätzte Fertigstellungszeit', plateNumber: 'Platte {{number}}', slotOptions: 'Slot-Optionen', diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 308e057faa..8c318a2901 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -735,6 +735,26 @@ export default { }, // HMS errors clickToViewHmsErrors: 'Click to view HMS errors', + aiDetection: { + safe: 'Safe', + warning: 'Warning', + failure: 'Failure', + idle: 'Idle', + error: 'Not checking', + unknown: 'Starting', + tooltipError: 'AI Failure Detection is not checking this print: {{reason}} - click for details', + tooltipUnknown: 'AI Failure Detection: waiting for the first result - click for details', + errorHint: 'This print is not being checked. Detection resumes automatically once the problem below is fixed.', + tooltip: 'AI Failure Detection: {{status}} (score {{score}}) - click for details', + tooltipIdle: 'AI Failure Detection enabled - monitoring starts with the next print - click for details', + modalTitle: 'AI Failure Detection - {{name}}', + currentStatus: 'Status', + score: 'Score', + framesAnalyzed: 'Frames analyzed', + idleHint: 'No print is being monitored right now. Monitoring starts automatically with the next print.', + lastError: 'Last error', + openSettings: 'Open settings', + }, estimatedCompletion: 'Estimated completion time', plateNumber: 'Plate {{number}}', slotOptions: 'Slot options', diff --git a/frontend/src/i18n/locales/es.ts b/frontend/src/i18n/locales/es.ts index 278e9e738f..924a940276 100644 --- a/frontend/src/i18n/locales/es.ts +++ b/frontend/src/i18n/locales/es.ts @@ -700,6 +700,26 @@ export default { }, // HMS errors clickToViewHmsErrors: 'Haga clic para ver los errores HMS', + aiDetection: { + safe: 'Seguro', + warning: 'Advertencia', + failure: 'Fallo', + idle: 'Inactiva', + error: 'Sin comprobar', + unknown: 'Iniciando', + tooltipError: 'La detección de fallos por IA no está comprobando esta impresión: {{reason}} - haga clic para más detalles', + tooltipUnknown: 'Detección de fallos por IA: esperando el primer resultado - haga clic para más detalles', + errorHint: 'Esta impresión no se está comprobando. La detección se reanudará automáticamente cuando se solucione el problema indicado abajo.', + tooltip: 'Detección de fallos por IA: {{status}} (puntuación {{score}}) - haga clic para más detalles', + tooltipIdle: 'Detección de fallos por IA activada - la supervisión comienza con la próxima impresión - haga clic para más detalles', + modalTitle: 'Detección de fallos por IA - {{name}}', + currentStatus: 'Estado', + score: 'Puntuación', + framesAnalyzed: 'Fotogramas analizados', + idleHint: 'Ahora mismo no se supervisa ninguna impresión. La supervisión comienza automáticamente con la próxima impresión.', + lastError: 'Último error', + openSettings: 'Abrir ajustes', + }, estimatedCompletion: 'Hora estimada de finalización', plateNumber: 'Cama {{number}}', slotOptions: 'Opciones de la ranura', diff --git a/frontend/src/i18n/locales/fr.ts b/frontend/src/i18n/locales/fr.ts index 36c69c7d31..bcf667cd73 100644 --- a/frontend/src/i18n/locales/fr.ts +++ b/frontend/src/i18n/locales/fr.ts @@ -694,6 +694,26 @@ export default { }, // HMS errors clickToViewHmsErrors: 'Cliquez pour voir les erreurs HMS', + aiDetection: { + safe: 'Sûr', + warning: 'Avertissement', + failure: 'Échec', + idle: 'Inactif', + error: 'Ne vérifie pas', + unknown: 'Démarrage', + tooltipError: 'La détection d\'échec par IA ne vérifie pas cette impression : {{reason}} - cliquez pour les détails', + tooltipUnknown: 'Détection d\'échec par IA : en attente du premier résultat - cliquez pour les détails', + errorHint: 'Cette impression n\'est pas vérifiée. La détection reprendra automatiquement une fois le problème ci-dessous résolu.', + tooltip: 'Détection d\'échec par IA : {{status}} (score {{score}}) - cliquez pour les détails', + tooltipIdle: 'Détection d\'échec par IA activée - la surveillance démarre à la prochaine impression - cliquez pour les détails', + modalTitle: 'Détection d\'échec par IA - {{name}}', + currentStatus: 'Statut', + score: 'Indice', + framesAnalyzed: 'Images analysées', + idleHint: 'Aucune impression n\'est surveillée actuellement. La surveillance démarre automatiquement à la prochaine impression.', + lastError: 'Dernière erreur', + openSettings: 'Ouvrir les paramètres', + }, estimatedCompletion: 'Fin estimée', plateNumber: 'Plaque {{number}}', slotOptions: 'Options du slot', diff --git a/frontend/src/i18n/locales/it.ts b/frontend/src/i18n/locales/it.ts index cf6d0289a5..76dce95219 100644 --- a/frontend/src/i18n/locales/it.ts +++ b/frontend/src/i18n/locales/it.ts @@ -694,6 +694,26 @@ export default { }, // HMS errors clickToViewHmsErrors: 'Clicca per vedere errori HMS', + aiDetection: { + safe: 'Sicuro', + warning: 'Avviso', + failure: 'Guasto', + idle: 'Inattiva', + error: 'Non controlla', + unknown: 'Avvio', + tooltipError: 'Il rilevamento guasti con IA non sta controllando questa stampa: {{reason}} - clicca per i dettagli', + tooltipUnknown: 'Rilevamento guasti con IA: in attesa del primo risultato - clicca per i dettagli', + errorHint: 'Questa stampa non viene controllata. Il rilevamento riprenderà automaticamente una volta risolto il problema indicato sotto.', + tooltip: 'Rilevamento guasti con IA: {{status}} (punteggio {{score}}) - clicca per i dettagli', + tooltipIdle: 'Rilevamento guasti con IA attivo - il monitoraggio inizia con la prossima stampa - clicca per i dettagli', + modalTitle: 'Rilevamento guasti con IA - {{name}}', + currentStatus: 'Stato', + score: 'Punteggio', + framesAnalyzed: 'Fotogrammi analizzati', + idleHint: 'Al momento nessuna stampa è monitorata. Il monitoraggio inizia automaticamente con la prossima stampa.', + lastError: 'Ultimo errore', + openSettings: 'Apri impostazioni', + }, estimatedCompletion: 'Tempo completamento stimato', plateNumber: 'Piastra {{number}}', slotOptions: 'Opzioni slot', diff --git a/frontend/src/i18n/locales/ja.ts b/frontend/src/i18n/locales/ja.ts index c2c7f42b97..72acd52562 100644 --- a/frontend/src/i18n/locales/ja.ts +++ b/frontend/src/i18n/locales/ja.ts @@ -693,6 +693,26 @@ export default { }, // HMS errors clickToViewHmsErrors: 'クリックしてHMSエラーを表示', + aiDetection: { + safe: '安全', + warning: '警告', + failure: '失敗', + idle: '待機中', + error: '確認していません', + unknown: '開始中', + tooltipError: 'AI 失敗検出はこの印刷を確認していません: {{reason}} - クリックで詳細を表示', + tooltipUnknown: 'AI 失敗検出: 最初の結果を待機中 - クリックで詳細を表示', + errorHint: 'この印刷は確認されていません。下記の問題が解決されると、検出は自動的に再開されます。', + tooltip: 'AI 失敗検出: {{status}}(スコア {{score}})- クリックで詳細を表示', + tooltipIdle: 'AI 失敗検出が有効 - 次の印刷から監視を開始します - クリックで詳細を表示', + modalTitle: 'AI 失敗検出 - {{name}}', + currentStatus: 'ステータス', + score: 'スコア', + framesAnalyzed: '解析フレーム数', + idleHint: '現在監視中の印刷はありません。次の印刷から自動的に監視を開始します。', + lastError: '最後のエラー', + openSettings: '設定を開く', + }, estimatedCompletion: '完了予定時刻', plateNumber: 'プレート {{number}}', slotOptions: 'スロットオプション', diff --git a/frontend/src/i18n/locales/ko.ts b/frontend/src/i18n/locales/ko.ts index 930f35b4a1..0233023bc5 100644 --- a/frontend/src/i18n/locales/ko.ts +++ b/frontend/src/i18n/locales/ko.ts @@ -649,6 +649,26 @@ export default { chamber: '챔버 팬' }, clickToViewHmsErrors: 'HMS 오류 보기 클릭', + aiDetection: { + safe: '안전', + warning: '경고', + failure: '실패', + idle: '대기 중', + error: '확인 안 함', + unknown: '시작 중', + tooltipError: 'AI 실패 감지가 이 인쇄를 확인하고 있지 않습니다: {{reason}} - 클릭하여 자세히 보기', + tooltipUnknown: 'AI 실패 감지: 첫 번째 결과를 기다리는 중 - 클릭하여 자세히 보기', + errorHint: '이 인쇄는 확인되고 있지 않습니다. 아래 문제가 해결되면 감지가 자동으로 재개됩니다.', + tooltip: 'AI 실패 감지: {{status}} (점수 {{score}}) - 클릭하여 자세히 보기', + tooltipIdle: 'AI 실패 감지 활성화됨 - 다음 인쇄부터 모니터링을 시작합니다 - 클릭하여 자세히 보기', + modalTitle: 'AI 실패 감지 - {{name}}', + currentStatus: '상태', + score: '점수', + framesAnalyzed: '분석된 프레임', + idleHint: '현재 모니터링 중인 인쇄가 없습니다. 다음 인쇄부터 자동으로 모니터링을 시작합니다.', + lastError: '마지막 오류', + openSettings: '설정 열기', + }, estimatedCompletion: '예상 완료 시간', plateNumber: '플레이트 {{number}}', slotOptions: '슬롯 옵션', diff --git a/frontend/src/i18n/locales/pt-BR.ts b/frontend/src/i18n/locales/pt-BR.ts index aca6202be4..543268fb90 100644 --- a/frontend/src/i18n/locales/pt-BR.ts +++ b/frontend/src/i18n/locales/pt-BR.ts @@ -694,6 +694,26 @@ export default { }, // HMS errors clickToViewHmsErrors: 'Clique para ver erros do HMS', + aiDetection: { + safe: 'Seguro', + warning: 'Aviso', + failure: 'Falha', + idle: 'Ocioso', + error: 'Não verificando', + unknown: 'Iniciando', + tooltipError: 'A Detecção de Falhas por IA não está verificando esta impressão: {{reason}} - clique para detalhes', + tooltipUnknown: 'Detecção de Falhas por IA: aguardando o primeiro resultado - clique para detalhes', + errorHint: 'Esta impressão não está sendo verificada. A detecção será retomada automaticamente assim que o problema abaixo for resolvido.', + tooltip: 'Detecção de Falhas por IA: {{status}} (pontuação {{score}}) - clique para detalhes', + tooltipIdle: 'Detecção de Falhas por IA ativada - o monitoramento começa na próxima impressão - clique para detalhes', + modalTitle: 'Detecção de Falhas por IA - {{name}}', + currentStatus: 'Status', + score: 'Pontuação', + framesAnalyzed: 'Quadros analisados', + idleHint: 'Nenhuma impressão está sendo monitorada no momento. O monitoramento começa automaticamente na próxima impressão.', + lastError: 'Último erro', + openSettings: 'Abrir configurações', + }, estimatedCompletion: 'Tempo estimado de conclusão', plateNumber: 'Placa {{number}}', slotOptions: 'Opções de slot', diff --git a/frontend/src/i18n/locales/tr.ts b/frontend/src/i18n/locales/tr.ts index 8ae550f1e1..3cd2d17800 100644 --- a/frontend/src/i18n/locales/tr.ts +++ b/frontend/src/i18n/locales/tr.ts @@ -693,6 +693,26 @@ export default { }, // HMS hataları clickToViewHmsErrors: 'HMS hatalarını görüntülemek için tıklayın', + aiDetection: { + safe: 'Güvenli', + warning: 'Uyarı', + failure: 'Başarısızlık', + idle: 'Boşta', + error: 'Kontrol etmiyor', + unknown: 'Başlatılıyor', + tooltipError: 'AI Başarısızlık Algılama bu baskıyı kontrol etmiyor: {{reason}} - ayrıntılar için tıklayın', + tooltipUnknown: 'AI Başarısızlık Algılama: ilk sonuç bekleniyor - ayrıntılar için tıklayın', + errorHint: 'Bu baskı kontrol edilmiyor. Aşağıdaki sorun giderildiğinde algılama otomatik olarak devam eder.', + tooltip: 'AI Başarısızlık Algılama: {{status}} (puan {{score}}) - ayrıntılar için tıklayın', + tooltipIdle: 'AI Başarısızlık Algılama etkin - izleme bir sonraki baskıyla başlar - ayrıntılar için tıklayın', + modalTitle: 'AI Başarısızlık Algılama - {{name}}', + currentStatus: 'Durum', + score: 'Puan', + framesAnalyzed: 'Analiz edilen kareler', + idleHint: 'Şu anda izlenen bir baskı yok. İzleme bir sonraki baskıyla otomatik olarak başlar.', + lastError: 'Son hata', + openSettings: 'Ayarları aç', + }, estimatedCompletion: 'Tahmini tamamlanma süresi', plateNumber: 'Plaka {{number}}', slotOptions: 'Yuva seçenekleri', diff --git a/frontend/src/i18n/locales/zh-CN.ts b/frontend/src/i18n/locales/zh-CN.ts index 24d02e1945..c128afe6e4 100644 --- a/frontend/src/i18n/locales/zh-CN.ts +++ b/frontend/src/i18n/locales/zh-CN.ts @@ -694,6 +694,26 @@ export default { }, // HMS errors clickToViewHmsErrors: '点击查看 HMS 错误', + aiDetection: { + safe: '安全', + warning: '警告', + failure: '失败', + idle: '空闲', + error: '未检测', + unknown: '启动中', + tooltipError: 'AI 故障检测未检测此次打印:{{reason}} - 点击查看详情', + tooltipUnknown: 'AI 故障检测:正在等待首个结果 - 点击查看详情', + errorHint: '本次打印未被检测。下方问题解决后,检测将自动恢复。', + tooltip: 'AI 故障检测:{{status}}(评分 {{score}})- 点击查看详情', + tooltipIdle: 'AI 故障检测已启用 - 下次打印时开始监控 - 点击查看详情', + modalTitle: 'AI 故障检测 - {{name}}', + currentStatus: '状态', + score: '评分', + framesAnalyzed: '已分析帧数', + idleHint: '当前没有正在监控的打印。下次打印时将自动开始监控。', + lastError: '最近错误', + openSettings: '打开设置', + }, estimatedCompletion: '预计完成时间', plateNumber: '板 {{number}}', slotOptions: '槽位选项', diff --git a/frontend/src/i18n/locales/zh-TW.ts b/frontend/src/i18n/locales/zh-TW.ts index a7b577ddfd..5aae88dac1 100644 --- a/frontend/src/i18n/locales/zh-TW.ts +++ b/frontend/src/i18n/locales/zh-TW.ts @@ -694,6 +694,26 @@ export default { }, // HMS errors clickToViewHmsErrors: '點選檢視 HMS 錯誤', + aiDetection: { + safe: '安全', + warning: '警告', + failure: '失敗', + idle: '空閒', + error: '未檢測', + unknown: '啟動中', + tooltipError: 'AI 故障檢測未檢測此次列印:{{reason}} - 點擊查看詳情', + tooltipUnknown: 'AI 故障檢測:正在等待首個結果 - 點擊查看詳情', + errorHint: '本次列印未被檢測。下方問題解決後,檢測將自動恢復。', + tooltip: 'AI 故障檢測:{{status}}(評分 {{score}})- 點擊查看詳情', + tooltipIdle: 'AI 故障檢測已啟用 - 下次列印時開始監控 - 點擊查看詳情', + modalTitle: 'AI 故障檢測 - {{name}}', + currentStatus: '狀態', + score: '評分', + framesAnalyzed: '已分析幀數', + idleHint: '目前沒有正在監控的列印。下次列印時將自動開始監控。', + lastError: '最近錯誤', + openSettings: '開啟設定', + }, estimatedCompletion: '預計完成時間', plateNumber: '板 {{number}}', slotOptions: '槽位選項', diff --git a/frontend/src/pages/printers/PrinterCard.tsx b/frontend/src/pages/printers/PrinterCard.tsx index 8c62953eea..dc8d2373a1 100644 --- a/frontend/src/pages/printers/PrinterCard.tsx +++ b/frontend/src/pages/printers/PrinterCard.tsx @@ -27,6 +27,7 @@ import { formatKValue, getEmptySlotKind } from './printer-card-utils'; import type { PrinterCardProps } from './printer-card-types'; import { usePrinterCardModel } from './usePrinterCardModel'; import { PrinterCardOverlays } from './PrinterCardOverlays'; +import { AiDetectionBadge } from '../../components/AiDetectionBadge'; export function PrinterCard(props: PrinterCardProps) { const model = usePrinterCardModel(props); @@ -414,12 +415,6 @@ return ( {viewMode === 'expanded' && (
- {/* Connection status badge (or Maintenance pill when out of service). - Defensive: only swap when is_active is EXPLICITLY false. An - undefined / missing field defaults to "active" so the regular - pill renders — matches the backend default and prevents test - fixtures (or stale clients) from accidentally tripping the - maintenance UI. */} {printer.is_active === false ? ( ); })()} + {/* Maintenance Status Indicator */} {maintenanceInfo && (