Add settings connection tests
This commit is contained in:
@@ -4,6 +4,7 @@ from pathlib import Path
|
||||
from typing import Annotated
|
||||
|
||||
import uvicorn
|
||||
import httpx
|
||||
from fastapi import Depends, FastAPI, Header, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
@@ -50,6 +51,12 @@ class AppSettingsUpdate(BaseModel):
|
||||
sonarr_api_key: str | None = None
|
||||
|
||||
|
||||
class ConnectionTestRequest(BaseModel):
|
||||
service: str
|
||||
url: str
|
||||
api_key: str | None = None
|
||||
|
||||
|
||||
def load_ui_settings() -> None:
|
||||
for key in ("sab_url", "sab_api_key", "radarr_url", "radarr_api_key", "sonarr_url", "sonarr_api_key"):
|
||||
stored = state.get_app_state(key)
|
||||
@@ -136,6 +143,33 @@ def update_ui_settings(payload: AppSettingsUpdate, _: None = Depends(require_wri
|
||||
return get_ui_settings()
|
||||
|
||||
|
||||
@app.post("/api/settings/test-connection")
|
||||
async def test_connection(payload: ConnectionTestRequest, _: None = Depends(require_write_auth)) -> dict[str, object]:
|
||||
service = payload.service.strip().lower()
|
||||
url = payload.url.strip().rstrip("/")
|
||||
api_key = (payload.api_key or "").strip() or None
|
||||
if service not in {"sabnzbd", "radarr", "sonarr"}:
|
||||
raise HTTPException(status_code=400, detail="service must be sabnzbd, radarr, or sonarr")
|
||||
if not url:
|
||||
raise HTTPException(status_code=400, detail="URL is required")
|
||||
if api_key is None:
|
||||
api_key = getattr(settings, f"{service if service != 'sabnzbd' else 'sab'}_api_key")
|
||||
try:
|
||||
if service == "sabnzbd":
|
||||
data = await SabnzbdClient(url, api_key).queue()
|
||||
return {"ok": True, "service": service, "message": f"Connected to SABnzbd; {len(data.get('queue', {}).get('slots', []))} queued jobs visible."}
|
||||
headers = {"X-Api-Key": api_key} if api_key else {}
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
response = await client.get(f"{url}/api/v3/system/status", headers=headers)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
name = str(data.get("appName") or service.title())
|
||||
version = str(data.get("version") or "unknown version")
|
||||
return {"ok": True, "service": service, "message": f"Connected to {name} {version}."}
|
||||
except Exception as exc:
|
||||
return {"ok": False, "service": service, "message": f"Connection failed: {exc.__class__.__name__}"}
|
||||
|
||||
|
||||
def control_status() -> dict[str, object]:
|
||||
mode = state.get_app_state("queue_mode", "running") or "running"
|
||||
current = state.get_app_state("current_job")
|
||||
|
||||
@@ -1 +1 @@
|
||||
body{font-family:system-ui,sans-serif;margin:0;background:#111827;color:#e5e7eb}header,main{max-width:1100px;margin:auto;padding:1rem}.topbar{display:flex;justify-content:space-between;gap:1rem;align-items:center;background:#0f172a}.build{text-align:right}.build strong{font-size:1.2rem}.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(12rem,1fr));gap:1rem}.cards article,.panel{background:#1f2937;border-radius:.75rem;padding:1rem;margin-top:1rem}strong{display:block;font-size:2rem}span,small,dd{color:#9ca3af}table{width:100%;border-collapse:collapse;background:#1f2937;margin-top:1rem}th,td{padding:.6rem;border-bottom:1px solid #374151;text-align:left;vertical-align:top}input,button{padding:.6rem;border-radius:.4rem;border:1px solid #374151}button{background:#38bdf8;color:#082f49;font-weight:700;cursor:pointer}.danger{background:#f87171;color:#450a0a}.warn{background:#fbbf24;color:#451a03}.controls,.row-actions{display:flex;gap:.5rem;flex-wrap:wrap}.inline-form{display:flex;gap:.5rem;flex-wrap:wrap}.inline-form input[name=path]{min-width:min(100%,28rem);flex:1}.info{display:grid;grid-template-columns:10rem 1fr;gap:.4rem 1rem}.info dt{font-weight:700}.info dd{margin:0;overflow-wrap:anywhere}.state{background:#0f172a;border:1px solid #374151;border-radius:999px;padding:.15rem .5rem;display:inline-block}.section-title{display:flex;align-items:center;justify-content:space-between;gap:1rem}.job-group{margin-top:1.25rem}.job-group h3{display:flex;gap:.5rem;align-items:center}.job-group h3 span{font-size:.9rem;border:1px solid #374151;border-radius:999px;padding:.1rem .45rem}.file-name{font-size:1rem}.row-actions button{padding:.35rem .5rem}td small{display:block;overflow-wrap:anywhere}dialog{background:#1f2937;color:#e5e7eb;border:1px solid #374151;border-radius:.75rem;max-width:min(42rem,90vw)}dialog::backdrop{background:#0009}fieldset{border:1px solid #374151;border-radius:.5rem;margin:1rem 0;padding:1rem}label{display:grid;gap:.35rem;margin:.75rem 0}.hint{color:#9ca3af}
|
||||
body{font-family:system-ui,sans-serif;margin:0;background:#111827;color:#e5e7eb}header,main{max-width:1100px;margin:auto;padding:1rem}.topbar{display:flex;justify-content:space-between;gap:1rem;align-items:center;background:#0f172a}.build{text-align:right}.build strong{font-size:1.2rem}.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(12rem,1fr));gap:1rem}.cards article,.panel{background:#1f2937;border-radius:.75rem;padding:1rem;margin-top:1rem}strong{display:block;font-size:2rem}span,small,dd{color:#9ca3af}table{width:100%;border-collapse:collapse;background:#1f2937;margin-top:1rem}th,td{padding:.6rem;border-bottom:1px solid #374151;text-align:left;vertical-align:top}input,button{padding:.6rem;border-radius:.4rem;border:1px solid #374151}button{background:#38bdf8;color:#082f49;font-weight:700;cursor:pointer}.danger{background:#f87171;color:#450a0a}.warn{background:#fbbf24;color:#451a03}.controls,.row-actions{display:flex;gap:.5rem;flex-wrap:wrap}.inline-form{display:flex;gap:.5rem;flex-wrap:wrap}.inline-form input[name=path]{min-width:min(100%,28rem);flex:1}.info{display:grid;grid-template-columns:10rem 1fr;gap:.4rem 1rem}.info dt{font-weight:700}.info dd{margin:0;overflow-wrap:anywhere}.state{background:#0f172a;border:1px solid #374151;border-radius:999px;padding:.15rem .5rem;display:inline-block}.section-title{display:flex;align-items:center;justify-content:space-between;gap:1rem}.job-group{margin-top:1.25rem}.job-group h3{display:flex;gap:.5rem;align-items:center}.job-group h3 span{font-size:.9rem;border:1px solid #374151;border-radius:999px;padding:.1rem .45rem}.file-name{font-size:1rem}.row-actions button{padding:.35rem .5rem}td small{display:block;overflow-wrap:anywhere}dialog{background:#1f2937;color:#e5e7eb;border:1px solid #374151;border-radius:.75rem;max-width:min(42rem,90vw)}dialog::backdrop{background:#0009}fieldset{border:1px solid #374151;border-radius:.5rem;margin:1rem 0;padding:1rem}label{display:grid;gap:.35rem;margin:.75rem 0}.hint{color:#9ca3af}output{display:block;margin-top:.5rem;color:#9ca3af}.success{color:#86efac}.error{color:#fca5a5}
|
||||
|
||||
@@ -74,16 +74,19 @@
|
||||
<legend>SABnzbd</legend>
|
||||
<label>SAB URL <input name="sab_url" type="url" value="{{ status.sab_url }}" placeholder="http://sabnzbd:8080" required></label>
|
||||
<label>API token <input name="sab_api_key" type="password" placeholder="{% if status.sab_api_key_configured %}Configured; enter a new token to replace{% else %}SAB API token{% endif %}" autocomplete="off"></label>
|
||||
<button type="button" data-test-connection="sabnzbd">Test SABnzbd connection</button><output id="sabnzbd-test-result"></output>
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>Radarr</legend>
|
||||
<label>Radarr URL <input name="radarr_url" type="url" value="{{ status.radarr_url }}" placeholder="http://radarr:7878"></label>
|
||||
<label>API token <input name="radarr_api_key" type="password" placeholder="{% if status.radarr_api_key_configured %}Configured; enter a new token to replace{% else %}Radarr API token{% endif %}" autocomplete="off"></label>
|
||||
<button type="button" data-test-connection="radarr">Test Radarr connection</button><output id="radarr-test-result"></output>
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>Sonarr</legend>
|
||||
<label>Sonarr URL <input name="sonarr_url" type="url" value="{{ status.sonarr_url }}" placeholder="http://sonarr:8989"></label>
|
||||
<label>API token <input name="sonarr_api_key" type="password" placeholder="{% if status.sonarr_api_key_configured %}Configured; enter a new token to replace{% else %}Sonarr API token{% endif %}" autocomplete="off"></label>
|
||||
<button type="button" data-test-connection="sonarr">Test Sonarr connection</button><output id="sonarr-test-result"></output>
|
||||
</fieldset>
|
||||
<p class="hint">Blank token fields clear the stored token. Environment values remain the startup defaults until saved here.</p>
|
||||
<button type="submit">Save settings</button>
|
||||
@@ -103,6 +106,7 @@
|
||||
document.getElementById('open-settings').addEventListener('click',()=>document.getElementById('settings-dialog').showModal());
|
||||
document.getElementById('close-settings').addEventListener('click',()=>document.getElementById('settings-dialog').close());
|
||||
document.getElementById('settings-form').addEventListener('submit', async e=>{ e.preventDefault(); const body=Object.fromEntries(new FormData(e.target)); const response=await postJson('/api/settings',body); if(response.ok){ const data=await response.json(); document.getElementById('sab-token-status').textContent=data.sab_api_key_configured?'configured':'not configured'; ['sab_api_key','radarr_api_key','sonarr_api_key'].forEach(name=>e.target.elements[name].value=''); document.getElementById('settings-dialog').close(); } });
|
||||
document.querySelectorAll('[data-test-connection]').forEach(button=>button.addEventListener('click', async()=>{ const form=document.getElementById('settings-form'); const service=button.dataset.testConnection; const prefix=service==='sabnzbd'?'sab':service; const output=document.getElementById(`${service}-test-result`); output.textContent='Testing…'; output.className=''; const response=await postJson('/api/settings/test-connection',{service,url:form.elements[`${prefix}_url`].value,api_key:form.elements[`${prefix}_api_key`].value}); if(response.ok){ const data=await response.json(); output.textContent=data.message; output.className=data.ok?'success':'error'; } }));
|
||||
document.getElementById('force-run').addEventListener('click', async()=>{ await postJson('/api/import/run-now',{force:true}); await refresh(); });
|
||||
refresh(); setInterval(refresh, 10000);
|
||||
</script>
|
||||
|
||||
@@ -46,6 +46,9 @@ def test_index_renders_settings_dialog(tmp_path, monkeypatch):
|
||||
assert "SABnzbd" in response.text
|
||||
assert "Radarr" in response.text
|
||||
assert "Sonarr" in response.text
|
||||
assert "Test SABnzbd connection" in response.text
|
||||
assert "Test Radarr connection" in response.text
|
||||
assert "Test Sonarr connection" in response.text
|
||||
|
||||
|
||||
def test_settings_endpoint_persists_arr_connection_values(tmp_path, monkeypatch):
|
||||
@@ -77,3 +80,37 @@ def test_settings_endpoint_persists_arr_connection_values(tmp_path, monkeypatch)
|
||||
}
|
||||
assert main.settings.sab_api_key == "sab-secret"
|
||||
assert main.state.get_app_state("radarr_api_key") == "radarr-secret"
|
||||
|
||||
|
||||
def test_sab_connection_test_reports_success(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("IMPORTARR_STATE_PATH", str(tmp_path / "state.db"))
|
||||
import importarr.main as main
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
async def fake_queue(self):
|
||||
return {"queue": {"slots": [{"name": "one"}, {"name": "two"}]}}
|
||||
|
||||
monkeypatch.setattr(main.SabnzbdClient, "queue", fake_queue)
|
||||
|
||||
response = TestClient(main.app).post(
|
||||
"/api/settings/test-connection",
|
||||
json={"service": "sabnzbd", "url": "http://sab:8080", "api_key": "secret"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"ok": True, "service": "sabnzbd", "message": "Connected to SABnzbd; 2 queued jobs visible."}
|
||||
|
||||
|
||||
def test_connection_test_rejects_unknown_service(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("IMPORTARR_STATE_PATH", str(tmp_path / "state.db"))
|
||||
import importarr.main as main
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
response = TestClient(main.app).post(
|
||||
"/api/settings/test-connection",
|
||||
json={"service": "lidarr", "url": "http://lidarr:8686"},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
|
||||
Reference in New Issue
Block a user