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:
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user