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.
This commit is contained in:
2026-02-12 11:56:32 +01:00
parent 2f5529a93a
commit 3e025bcf1b
4 changed files with 117 additions and 5 deletions

View File

@@ -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.models.models import Metric, QueryStat, Target, User
from app.schemas.metric import MetricOut, QueryStatOut from app.schemas.metric import MetricOut, QueryStatOut
from app.schemas.overview import DatabaseOverviewOut 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.audit import write_audit_log
from app.services.collector import build_target_dsn from app.services.collector import build_target_dsn
from app.services.crypto import encrypt_secret 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] 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) @router.post("", response_model=TargetOut, status_code=status.HTTP_201_CREATED)
async def create_target( async def create_target(
payload: TargetCreate, payload: TargetCreate,

View File

@@ -16,6 +16,15 @@ class TargetCreate(TargetBase):
password: str password: str
class TargetConnectionTestRequest(BaseModel):
host: str
port: int = 5432
dbname: str
username: str
password: str
sslmode: str = "prefer"
class TargetUpdate(BaseModel): class TargetUpdate(BaseModel):
name: str | None = None name: str | None = None
host: str | None = None host: str | None = None

View File

@@ -20,6 +20,7 @@ export function TargetsPage() {
const [form, setForm] = useState(emptyForm); const [form, setForm] = useState(emptyForm);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [testState, setTestState] = useState({ loading: false, message: "", ok: null });
const canManage = me?.role === "admin" || me?.role === "operator"; 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) => { const deleteTarget = async (id) => {
if (!confirm("Delete target?")) return; if (!confirm("Delete target?")) return;
try { try {
@@ -66,7 +92,7 @@ export function TargetsPage() {
{error && <div className="card error">{error}</div>} {error && <div className="card error">{error}</div>}
{canManage && ( {canManage && (
<details className="card collapsible" open> <details className="card collapsible">
<summary className="collapse-head"> <summary className="collapse-head">
<div> <div>
<h3>New Target</h3> <h3>New Target</h3>
@@ -111,9 +137,17 @@ export function TargetsPage() {
</small> </small>
</div> </div>
<div className="field submit-field"> <div className="field submit-field">
<button className="primary-btn">Create target</button> <div className="target-actions">
<button type="button" className="secondary-btn" onClick={testConnection} disabled={testState.loading}>
{testState.loading ? "Testing..." : "Test connection"}
</button>
<button className="primary-btn">Create target</button>
</div>
</div> </div>
</form> </form>
{testState.message && (
<div className={`test-connection-result ${testState.ok ? "ok" : "fail"}`}>{testState.message}</div>
)}
</details> </details>
)} )}

View File

@@ -269,8 +269,8 @@ button {
border-color: #3384cb; border-color: #3384cb;
background: linear-gradient(180deg, #15528d, #114170); background: linear-gradient(180deg, #15528d, #114170);
box-shadow: inset 0 1px 0 #5f8de144; box-shadow: inset 0 1px 0 #5f8de144;
padding: 7px 12px; padding: 6px 12px;
min-height: 38px; min-height: 34px;
} }
.primary-btn:hover { .primary-btn:hover {
@@ -278,6 +278,48 @@ button {
background: linear-gradient(180deg, #1a63a9, #14558f); 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 { .collapsible {
padding-top: 12px; padding-top: 12px;
} }