diff --git a/backend/app/clients/qbittorrent.py b/backend/app/clients/qbittorrent.py index fc70d1f..5cb9f91 100644 --- a/backend/app/clients/qbittorrent.py +++ b/backend/app/clients/qbittorrent.py @@ -52,6 +52,17 @@ class QBittorrentClient(ApiClient): response = await client.post(f"{self.base_url}{path}", data=data) response.raise_for_status() + async def is_webui_reachable(self) -> bool: + if not self.base_url: + return False + try: + async with httpx.AsyncClient(timeout=10.0, follow_redirects=True) as client: + response = await client.get(self.base_url) + response.raise_for_status() + return True + except httpx.HTTPError: + return False + async def get_torrents(self) -> Optional[Any]: return await self._get("/api/v2/torrents/info") diff --git a/backend/app/routers/status.py b/backend/app/routers/status.py index d2c60a2..3d38730 100644 --- a/backend/app/routers/status.py +++ b/backend/app/routers/status.py @@ -26,6 +26,35 @@ async def _check(name: str, configured: bool, func) -> Dict[str, Any]: return {"name": name, "status": "down", "message": str(exc)} +async def _check_qbittorrent(qbittorrent: QBittorrentClient) -> Dict[str, Any]: + if not qbittorrent.base_url: + return {"name": "qBittorrent", "status": "not_configured"} + if not qbittorrent.username or not qbittorrent.password: + reachable = await qbittorrent.is_webui_reachable() + return { + "name": "qBittorrent", + "status": "degraded" if reachable else "not_configured", + "message": "qBittorrent credentials are incomplete" if reachable else "qBittorrent is not fully configured", + } + try: + result = await qbittorrent.get_app_version() + return {"name": "qBittorrent", "status": "up", "detail": result} + except RuntimeError as exc: + if "login failed" in str(exc).lower(): + reachable = await qbittorrent.is_webui_reachable() + if reachable: + return { + "name": "qBittorrent", + "status": "degraded", + "message": "qBittorrent is reachable but the saved credentials were rejected", + } + return {"name": "qBittorrent", "status": "down", "message": str(exc)} + except httpx.HTTPError as exc: + return {"name": "qBittorrent", "status": "down", "message": str(exc)} + except Exception as exc: + return {"name": "qBittorrent", "status": "down", "message": str(exc)} + + @router.get("/services") async def services_status() -> Dict[str, Any]: runtime = get_runtime_settings() @@ -71,13 +100,7 @@ async def services_status() -> Dict[str, Any]: prowlarr_status["status"] = "degraded" prowlarr_status["message"] = "Health warnings" services.append(prowlarr_status) - services.append( - await _check( - "qBittorrent", - qbittorrent.configured(), - qbittorrent.get_app_version, - ) - ) + services.append(await _check_qbittorrent(qbittorrent)) services.append( await _check( "Jellyfin", @@ -122,10 +145,12 @@ async def test_service(service: str) -> Dict[str, Any]: "sonarr": ("Sonarr", sonarr.configured(), sonarr.get_system_status), "radarr": ("Radarr", radarr.configured(), radarr.get_system_status), "prowlarr": ("Prowlarr", prowlarr.configured(), prowlarr.get_health), - "qbittorrent": ("qBittorrent", qbittorrent.configured(), qbittorrent.get_app_version), "jellyfin": ("Jellyfin", jellyfin.configured(), jellyfin.get_system_info), } + if service_key == "qbittorrent": + return await _check_qbittorrent(qbittorrent) + if service_key not in checks: raise HTTPException(status_code=404, detail="Unknown service") diff --git a/backend/tests/test_backend_quality.py b/backend/tests/test_backend_quality.py index 4bcd3ef..a8bfddd 100644 --- a/backend/tests/test_backend_quality.py +++ b/backend/tests/test_backend_quality.py @@ -11,6 +11,7 @@ from backend.app.config import settings from backend.app.network_security import request_trusts_forwarded_headers, validate_notification_target_url from backend.app.routers import auth as auth_router from backend.app.routers import portal as portal_router +from backend.app.routers import status as status_router from backend.app.security import PASSWORD_POLICY_MESSAGE, validate_password_policy from backend.app.services import password_reset @@ -94,6 +95,28 @@ class NetworkSecurityTests(unittest.TestCase): settings.magent_proxy_trusted_proxies = original_proxies +class ServiceStatusTests(unittest.IsolatedAsyncioTestCase): + async def test_qbittorrent_incomplete_credentials_report_degraded_when_reachable(self) -> None: + client = status_router.QBittorrentClient("http://10.0.0.2:8080", "admin", None) + with patch.object(client, "is_webui_reachable", new=AsyncMock(return_value=True)): + result = await status_router._check_qbittorrent(client) + + self.assertEqual(result["status"], "degraded") + self.assertIn("credentials", result["message"].lower()) + + async def test_qbittorrent_rejected_credentials_report_degraded_when_reachable(self) -> None: + client = status_router.QBittorrentClient("http://10.0.0.2:8080", "admin", "secret") + with patch.object( + client, + "get_app_version", + new=AsyncMock(side_effect=RuntimeError("qBittorrent login failed")), + ), patch.object(client, "is_webui_reachable", new=AsyncMock(return_value=True)): + result = await status_router._check_qbittorrent(client) + + self.assertEqual(result["status"], "degraded") + self.assertIn("credentials", result["message"].lower()) + + class DatabaseEmailTests(TempDatabaseMixin, unittest.TestCase): def test_set_user_email_is_case_insensitive(self) -> None: created = db.create_user_if_missing( diff --git a/frontend/app/globals.css b/frontend/app/globals.css index 2aa5a05..3d27011 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -3565,12 +3565,14 @@ button:disabled { .user-grid-pill.is-blocked { background: rgba(244, 114, 114, 0.14); border-color: rgba(244, 114, 114, 0.24); + color: #ffd5d5; } .system-pill-degraded, .user-grid-pill.is-disabled { background: rgba(208, 166, 92, 0.14); border-color: rgba(208, 166, 92, 0.22); + color: #ffe3a6; } .system-dot {