Add service test buttons (build 271261335)

This commit is contained in:
2026-01-27 13:36:35 +13:00
parent d23d84ea42
commit 40dc46c0c5
4 changed files with 163 additions and 15 deletions

View File

@@ -1 +1 @@
271261322 271261335

View File

@@ -1,6 +1,6 @@
from typing import Any, Dict from typing import Any, Dict
import httpx import httpx
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends, HTTPException
from ..auth import get_current_user from ..auth import get_current_user
from ..runtime import get_runtime_settings from ..runtime import get_runtime_settings
@@ -93,3 +93,42 @@ async def services_status() -> Dict[str, Any]:
overall = "degraded" overall = "degraded"
return {"overall": overall, "services": services} 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

View File

@@ -1415,6 +1415,24 @@ button span {
font-size: 13px; 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 { .system-dot {
width: 10px; width: 10px;
height: 10px; height: 10px;
@@ -1444,10 +1462,28 @@ button span {
} }
.system-state { .system-state {
margin-left: auto;
color: var(--ink-muted); 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 { .pipeline-map {
border-radius: 16px; border-radius: 16px;
border: 1px solid var(--border); border: 1px solid var(--border);

View File

@@ -30,6 +30,8 @@ export default function HomePage() {
>(null) >(null)
const [servicesLoading, setServicesLoading] = useState(false) const [servicesLoading, setServicesLoading] = useState(false)
const [servicesError, setServicesError] = useState<string | null>(null) const [servicesError, setServicesError] = useState<string | null>(null)
const [serviceTesting, setServiceTesting] = useState<Record<string, boolean>>({})
const [serviceTestResults, setServiceTestResults] = useState<Record<string, string | null>>({})
const submit = (event: React.FormEvent) => { const submit = (event: React.FormEvent) => {
event.preventDefault() event.preventDefault()
@@ -42,6 +44,61 @@ export default function HomePage() {
void runSearch(trimmed) 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(() => { useEffect(() => {
if (!getToken()) { if (!getToken()) {
router.push('/login') router.push('/login')
@@ -214,10 +271,17 @@ export default function HomePage() {
return order.map((name) => { return order.map((name) => {
const item = items.find((entry) => entry.name === name) const item = items.find((entry) => entry.name === name)
const status = item?.status ?? 'unknown' const status = item?.status ?? 'unknown'
const testing = serviceTesting[name] ?? false
return ( return (
<div key={name} className={`system-item system-${status}`}> <div key={name} className={`system-item system-${status}`}>
<span className="system-dot" /> <span className="system-dot" />
<div className="system-meta">
<span className="system-name">{name}</span> <span className="system-name">{name}</span>
{serviceTestResults[name] && (
<span className="system-test-message">{serviceTestResults[name]}</span>
)}
</div>
<div className="system-actions">
<span className="system-state"> <span className="system-state">
{status === 'up' {status === 'up'
? 'Up' ? 'Up'
@@ -229,6 +293,15 @@ export default function HomePage() {
? 'Not configured' ? 'Not configured'
: 'Unknown'} : 'Unknown'}
</span> </span>
<button
type="button"
className="system-test"
onClick={() => void testService(name)}
disabled={testing}
>
{testing ? 'Testing...' : 'Test'}
</button>
</div>
</div> </div>
) )
}) })