diff --git a/.build_number b/.build_number index af13fd5..22f557e 100644 --- a/.build_number +++ b/.build_number @@ -1 +1 @@ -271261322 +271261335 diff --git a/backend/app/routers/status.py b/backend/app/routers/status.py index 692dde1..2c5a941 100644 --- a/backend/app/routers/status.py +++ b/backend/app/routers/status.py @@ -1,6 +1,6 @@ from typing import Any, Dict import httpx -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, HTTPException from ..auth import get_current_user from ..runtime import get_runtime_settings @@ -93,3 +93,42 @@ async def services_status() -> Dict[str, Any]: overall = "degraded" return {"overall": overall, "services": services} + + +@router.post("/services/{service}/test") +async def test_service(service: str) -> Dict[str, Any]: + runtime = get_runtime_settings() + jellyseerr = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key) + sonarr = SonarrClient(runtime.sonarr_base_url, runtime.sonarr_api_key) + radarr = RadarrClient(runtime.radarr_base_url, runtime.radarr_api_key) + prowlarr = ProwlarrClient(runtime.prowlarr_base_url, runtime.prowlarr_api_key) + qbittorrent = QBittorrentClient( + runtime.qbittorrent_base_url, runtime.qbittorrent_username, runtime.qbittorrent_password + ) + jellyfin = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key) + + service_key = service.strip().lower() + checks = { + "jellyseerr": ( + "Jellyseerr", + jellyseerr.configured(), + lambda: jellyseerr.get_recent_requests(take=1, skip=0), + ), + "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 not in checks: + raise HTTPException(status_code=404, detail="Unknown service") + + name, configured, func = checks[service_key] + result = await _check(name, configured, func) + if name == "Prowlarr" and result.get("status") == "up": + health = result.get("detail") + if isinstance(health, list) and health: + result["status"] = "degraded" + result["message"] = "Health warnings" + return result diff --git a/frontend/app/globals.css b/frontend/app/globals.css index 0c62f99..a0f359b 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -1415,6 +1415,24 @@ button span { font-size: 13px; } +.system-meta { + display: flex; + flex-direction: column; + gap: 2px; +} + +.system-test-message { + font-size: 11px; + color: var(--ink-muted); +} + +.system-actions { + margin-left: auto; + display: flex; + align-items: center; + gap: 8px; +} + .system-dot { width: 10px; height: 10px; @@ -1444,10 +1462,28 @@ button span { } .system-state { - margin-left: auto; color: var(--ink-muted); } +.system-test { + padding: 4px 10px; + border-radius: 999px; + border: 1px solid var(--border); + background: rgba(255, 255, 255, 0.08); + color: var(--ink-muted); + font-size: 11px; + letter-spacing: 0.02em; +} + +.system-test:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.16); +} + +.system-test:disabled { + opacity: 0.6; + cursor: not-allowed; +} + .pipeline-map { border-radius: 16px; border: 1px solid var(--border); diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index ac85ce3..2e2a589 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -30,6 +30,8 @@ export default function HomePage() { >(null) const [servicesLoading, setServicesLoading] = useState(false) const [servicesError, setServicesError] = useState(null) + const [serviceTesting, setServiceTesting] = useState>({}) + const [serviceTestResults, setServiceTestResults] = useState>({}) const submit = (event: React.FormEvent) => { event.preventDefault() @@ -42,6 +44,61 @@ export default function HomePage() { void runSearch(trimmed) } + const toServiceSlug = (name: string) => name.toLowerCase().replace(/[^a-z0-9]/g, '') + + const updateServiceStatus = (name: string, status: string, message?: string) => { + setServicesStatus((prev) => { + if (!prev) return prev + return { + ...prev, + services: prev.services.map((service) => + service.name === name ? { ...service, status, message } : service + ), + } + }) + } + + const testService = async (name: string) => { + const slug = toServiceSlug(name) + setServiceTesting((prev) => ({ ...prev, [name]: true })) + setServiceTestResults((prev) => ({ ...prev, [name]: null })) + try { + const baseUrl = getApiBase() + const response = await authFetch(`${baseUrl}/status/services/${slug}/test`, { + method: 'POST', + }) + if (!response.ok) { + if (response.status === 401) { + clearToken() + router.push('/login') + return + } + const text = await response.text() + throw new Error(text || `Service test failed: ${response.status}`) + } + const data = await response.json() + const status = data?.status ?? 'unknown' + const message = + data?.message || + (status === 'up' + ? 'API OK' + : status === 'down' + ? 'API unreachable' + : status === 'degraded' + ? 'Health warnings' + : status === 'not_configured' + ? 'Not configured' + : 'Unknown') + setServiceTestResults((prev) => ({ ...prev, [name]: message })) + updateServiceStatus(name, status, data?.message) + } catch (error) { + console.error(error) + setServiceTestResults((prev) => ({ ...prev, [name]: 'Test failed' })) + } finally { + setServiceTesting((prev) => ({ ...prev, [name]: false })) + } + } + useEffect(() => { if (!getToken()) { router.push('/login') @@ -214,21 +271,37 @@ export default function HomePage() { return order.map((name) => { const item = items.find((entry) => entry.name === name) const status = item?.status ?? 'unknown' + const testing = serviceTesting[name] ?? false return (
- {name} - - {status === 'up' - ? 'Up' - : status === 'down' - ? 'Down' - : status === 'degraded' - ? 'Needs attention' - : status === 'not_configured' - ? 'Not configured' - : 'Unknown'} - +
+ {name} + {serviceTestResults[name] && ( + {serviceTestResults[name]} + )} +
+
+ + {status === 'up' + ? 'Up' + : status === 'down' + ? 'Down' + : status === 'degraded' + ? 'Needs attention' + : status === 'not_configured' + ? 'Not configured' + : 'Unknown'} + + +
) })