diff --git a/backend/app/clients/qbittorrent.py b/backend/app/clients/qbittorrent.py index 5cb9f91..5362f4a 100644 --- a/backend/app/clients/qbittorrent.py +++ b/backend/app/clients/qbittorrent.py @@ -23,7 +23,9 @@ class QBittorrentClient(ApiClient): headers={"Referer": self.base_url}, ) response.raise_for_status() - if response.text.strip().lower() != "ok.": + text = response.text.strip().lower() + has_session_cookie = any(name.upper().startswith("QBT_SID") for name in client.cookies.keys()) + if text not in {"ok.", ""} or (text == "" and not has_session_cookie): raise RuntimeError("qBittorrent login failed") async def _get(self, path: str, params: Optional[Dict[str, Any]] = None) -> Optional[Any]: diff --git a/backend/tests/test_backend_quality.py b/backend/tests/test_backend_quality.py index ea7c226..1ed7b5b 100644 --- a/backend/tests/test_backend_quality.py +++ b/backend/tests/test_backend_quality.py @@ -3,6 +3,7 @@ import tempfile import unittest from unittest.mock import AsyncMock, patch +import httpx from fastapi import HTTPException from starlette.requests import Request @@ -97,6 +98,19 @@ class NetworkSecurityTests(unittest.TestCase): class ServiceStatusTests(unittest.IsolatedAsyncioTestCase): + async def test_qbittorrent_login_accepts_modern_empty_response_with_session_cookie(self) -> None: + class FakeClient: + def __init__(self) -> None: + self.cookies = httpx.Cookies() + + async def post(self, *_args, **_kwargs) -> httpx.Response: + self.cookies.set("QBT_SID_8080", "session") + return httpx.Response(204, request=httpx.Request("POST", "http://10.0.0.2:8080/api/v2/auth/login")) + + client = status_router.QBittorrentClient("http://10.0.0.2:8080", "admin", "secret") + + await client._login(FakeClient()) + 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)): diff --git a/frontend/app/ops-redesign.css b/frontend/app/ops-redesign.css index 3c72d1b..ad796c4 100644 --- a/frontend/app/ops-redesign.css +++ b/frontend/app/ops-redesign.css @@ -598,6 +598,86 @@ button:disabled, gap: 16px; } +.system-status-dropdown { + display: block; + margin-bottom: 16px; +} + +.system-status-dropdown summary { + list-style: none; +} + +.system-status-dropdown summary::-webkit-details-marker { + display: none; +} + +.system-summary { + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; + padding: 14px 16px; + cursor: pointer; + border: 1px solid var(--ops-line-soft); + border-radius: var(--ops-radius); + background: rgba(255, 255, 255, 0.032); +} + +.system-summary-copy { + display: grid; + gap: 4px; + min-width: 0; +} + +.system-summary-copy strong { + color: var(--ops-text); + font-size: 1rem; +} + +.system-summary-copy span:last-child { + color: var(--ops-muted); + font-size: 0.86rem; +} + +.system-summary-actions { + display: inline-flex; + align-items: center; + gap: 10px; + flex: 0 0 auto; +} + +.system-dropdown-cue { + min-width: 52px; + color: var(--ops-cyan); + font-family: "JetBrains Mono", Consolas, monospace; + font-size: 0.72rem; + font-weight: 700; + text-align: right; + text-transform: uppercase; +} + +.system-status-dropdown[open] .system-dropdown-cue::before { + content: "Close"; + font-size: 0.72rem; +} + +.system-status-dropdown[open] .system-dropdown-cue { + font-size: 0; +} + +.system-status-dropdown .system-list { + margin-top: 10px; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); +} + +.system-status-dropdown .system-item { + grid-template-columns: auto minmax(0, 1fr) auto; +} + +.system-status-dropdown .system-actions { + flex-wrap: nowrap; +} + .system-header { display: flex; align-items: center; @@ -658,6 +738,26 @@ button:disabled, font-size: 0.78rem; } +.find-panel .find-header { + display: grid; + gap: 6px; +} + +.find-panel .find-header h1 { + margin: 0; + font-size: clamp(1.12rem, 1.5vw, 1.38rem); + line-height: 1.12; +} + +.find-panel h2 { + font-size: 1.22rem; +} + +.find-panel .lede { + font-size: 0.9rem; + line-height: 1.45; +} + .system-actions { display: inline-flex; align-items: center; @@ -679,6 +779,7 @@ button:disabled, font-family: "JetBrains Mono", Consolas, monospace; font-size: 0.7rem; font-weight: 700; + white-space: nowrap; text-transform: uppercase; } diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index f5ec606..6959a11 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -367,6 +367,29 @@ export default function HomePage() { const serviceAttentionCount = serviceItems.filter((service) => ['down', 'degraded', 'not_configured'].includes(service.status) ).length + const serviceOverall = servicesStatus?.overall ?? 'unknown' + const serviceStatusLabel = servicesLoading + ? 'Checking services...' + : servicesError + ? 'Status not available yet' + : serviceOverall === 'up' + ? 'Services are up and running' + : serviceOverall === 'down' + ? 'Something is down' + : 'Some services need attention' + const serviceSummary = servicesError + ? 'Unable to load service status' + : serviceItems.length === 0 + ? 'No services reported yet' + : serviceAttentionCount > 0 + ? `${serviceAttentionCount} of ${serviceItems.length} need attention` + : `${serviceUpCount} of ${serviceItems.length} online` + const orderedServices = ['Seerr', 'Sonarr', 'Radarr', 'Prowlarr', 'qBittorrent', 'Jellyfin'].map( + (name) => { + const item = serviceItems.find((entry) => entry.name === name) + return { name, status: item?.status ?? 'unknown', message: item?.message } + } + ) const activeRecentCount = recent.filter((item) => { const label = String(item.statusLabel ?? '').toLowerCase() return !label.includes('ready') && !label.includes('available') && !label.includes('declined') @@ -400,74 +423,58 @@ export default function HomePage() {