From 3e025bcf1be4b0293de39382abec23d42a5384dc Mon Sep 17 00:00:00 2001 From: nessi Date: Thu, 12 Feb 2026 11:56:32 +0100 Subject: [PATCH] Add test connection feature for database targets This commit introduces a new endpoint to test database connection. The frontend now includes a button to test the connection before creating a target, with real-time feedback on success or failure. Related styles and components were updated for better user experience. --- backend/app/api/routes/targets.py | 29 ++++++++++++++++++- backend/app/schemas/target.py | 9 ++++++ frontend/src/pages/TargetsPage.jsx | 38 ++++++++++++++++++++++-- frontend/src/styles.css | 46 ++++++++++++++++++++++++++++-- 4 files changed, 117 insertions(+), 5 deletions(-) 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 &&
{error}
} {canManage && ( -
+

New Target

@@ -111,9 +137,17 @@ export function TargetsPage() {
- +
+ + +
+ {testState.message && ( +
{testState.message}
+ )}
)} diff --git a/frontend/src/styles.css b/frontend/src/styles.css index bdfb9f0..6ecb3ca 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -269,8 +269,8 @@ button { border-color: #3384cb; background: linear-gradient(180deg, #15528d, #114170); box-shadow: inset 0 1px 0 #5f8de144; - padding: 7px 12px; - min-height: 38px; + padding: 6px 12px; + min-height: 34px; } .primary-btn:hover { @@ -278,6 +278,48 @@ button { background: linear-gradient(180deg, #1a63a9, #14558f); } +.submit-field { + align-self: end; +} + +.target-actions { + display: flex; + justify-content: flex-end; + gap: 8px; +} + +.secondary-btn { + font-weight: 600; + border-color: #3f6ea9; + background: linear-gradient(180deg, #14365f, #102c4f); + padding: 6px 12px; + min-height: 34px; +} + +.secondary-btn:hover { + border-color: #69a9de; +} + +.test-connection-result { + margin-top: 10px; + font-size: 13px; + border-radius: 10px; + padding: 8px 10px; + border: 1px solid transparent; +} + +.test-connection-result.ok { + color: #b9f3cf; + border-color: #2f8f63; + background: #123727; +} + +.test-connection-result.fail { + color: #fecaca; + border-color: #b64a4a; + background: #3a1c22; +} + .collapsible { padding-top: 12px; }