diff --git a/backend/app/api/routes/targets.py b/backend/app/api/routes/targets.py index a3dfb8c..cb8c068 100644 --- a/backend/app/api/routes/targets.py +++ b/backend/app/api/routes/targets.py @@ -8,7 +8,7 @@ from app.core.deps import get_current_user, require_roles from app.models.models import Metric, QueryStat, Target, User from app.schemas.metric import MetricOut, QueryStatOut from app.schemas.overview import DatabaseOverviewOut -from app.schemas.target import TargetCreate, TargetOut, TargetUpdate +from app.schemas.target import TargetConnectionTestRequest, TargetCreate, TargetOut, TargetUpdate from app.services.audit import write_audit_log from app.services.collector import build_target_dsn from app.services.crypto import encrypt_secret @@ -23,6 +23,33 @@ async def list_targets(user: User = Depends(get_current_user), db: AsyncSession return [TargetOut.model_validate(item) for item in targets] +@router.post("/test-connection") +async def test_target_connection( + payload: TargetConnectionTestRequest, + user: User = Depends(require_roles("admin", "operator")), +) -> dict: + _ = user + ssl = False if payload.sslmode == "disable" else True + conn = None + try: + conn = await asyncpg.connect( + host=payload.host, + port=payload.port, + database=payload.dbname, + user=payload.username, + password=payload.password, + ssl=ssl, + timeout=8, + ) + version = await conn.fetchval("SHOW server_version") + return {"ok": True, "message": "Connection successful", "server_version": version} + except Exception as exc: + raise HTTPException(status_code=400, detail=f"Connection failed: {exc}") + finally: + if conn: + await conn.close() + + @router.post("", response_model=TargetOut, status_code=status.HTTP_201_CREATED) async def create_target( payload: TargetCreate, diff --git a/backend/app/schemas/target.py b/backend/app/schemas/target.py index a28a0bc..8601b13 100644 --- a/backend/app/schemas/target.py +++ b/backend/app/schemas/target.py @@ -16,6 +16,15 @@ class TargetCreate(TargetBase): password: str +class TargetConnectionTestRequest(BaseModel): + host: str + port: int = 5432 + dbname: str + username: str + password: str + sslmode: str = "prefer" + + class TargetUpdate(BaseModel): name: str | None = None host: str | None = None diff --git a/frontend/src/pages/TargetsPage.jsx b/frontend/src/pages/TargetsPage.jsx index 4b962d0..6172970 100644 --- a/frontend/src/pages/TargetsPage.jsx +++ b/frontend/src/pages/TargetsPage.jsx @@ -20,6 +20,7 @@ export function TargetsPage() { const [form, setForm] = useState(emptyForm); const [error, setError] = useState(""); const [loading, setLoading] = useState(true); + const [testState, setTestState] = useState({ loading: false, message: "", ok: null }); const canManage = me?.role === "admin" || me?.role === "operator"; @@ -50,6 +51,31 @@ export function TargetsPage() { } }; + const testConnection = async () => { + setTestState({ loading: true, message: "", ok: null }); + try { + const result = await apiFetch( + "/targets/test-connection", + { + method: "POST", + body: JSON.stringify({ + host: form.host, + port: form.port, + dbname: form.dbname, + username: form.username, + password: form.password, + sslmode: form.sslmode, + }), + }, + tokens, + refresh + ); + setTestState({ loading: false, message: `${result.message} (PostgreSQL ${result.server_version})`, ok: true }); + } catch (e) { + setTestState({ loading: false, message: String(e.message || e), ok: false }); + } + }; + const deleteTarget = async (id) => { if (!confirm("Delete target?")) return; try { @@ -66,7 +92,7 @@ export function TargetsPage() { {error &&