import React, { useEffect, useRef, useState } from "react";
import { Link } from "react-router-dom";
import { apiFetch } from "../api";
import { useAuth } from "../state";
const emptyForm = {
name: "",
host: "",
port: 5432,
dbname: "postgres",
username: "",
password: "",
sslmode: "prefer",
use_pg_stat_statements: true,
discover_all_databases: false,
owner_user_ids: [],
tags: {},
};
const emptyEditForm = {
id: null,
name: "",
host: "",
port: 5432,
dbname: "",
username: "",
password: "",
sslmode: "prefer",
use_pg_stat_statements: true,
owner_user_ids: [],
};
function toggleOwner(ids, userId) {
return ids.includes(userId) ? ids.filter((id) => id !== userId) : [...ids, userId];
}
function OwnerPicker({ candidates, selectedIds, onToggle, query, onQueryChange }) {
const pickerRef = useRef(null);
const [open, setOpen] = useState(false);
const filtered = candidates.filter((item) =>
item.email.toLowerCase().includes(query.trim().toLowerCase())
);
const selected = candidates.filter((item) => selectedIds.includes(item.user_id));
useEffect(() => {
const onPointerDown = (event) => {
if (!pickerRef.current?.contains(event.target)) {
setOpen(false);
}
};
document.addEventListener("mousedown", onPointerDown);
return () => document.removeEventListener("mousedown", onPointerDown);
}, []);
return (
{selected.length > 0 ? (
selected.map((item) => (
))
) : (
No owners selected yet.
)}
onQueryChange(e.target.value)}
onFocus={() => setOpen(true)}
onClick={() => setOpen(true)}
placeholder="Search users by email..."
/>
{open && (
{filtered.map((item) => {
const active = selectedIds.includes(item.user_id);
return (
);
})}
{filtered.length === 0 &&
No matching users.
}
)}
);
}
export function TargetsPage() {
const { tokens, refresh, me } = useAuth();
const [targets, setTargets] = useState([]);
const [form, setForm] = useState(emptyForm);
const [editForm, setEditForm] = useState(emptyEditForm);
const [editing, setEditing] = useState(false);
const [error, setError] = useState("");
const [loading, setLoading] = useState(true);
const [testState, setTestState] = useState({ loading: false, message: "", ok: null });
const [saveState, setSaveState] = useState({ loading: false, message: "" });
const [ownerCandidates, setOwnerCandidates] = useState([]);
const [createOwnerQuery, setCreateOwnerQuery] = useState("");
const [editOwnerQuery, setEditOwnerQuery] = useState("");
const canManage = me?.role === "admin" || me?.role === "operator";
const load = async () => {
setLoading(true);
try {
if (canManage) {
const [targetRows, candidates] = await Promise.all([
apiFetch("/targets", {}, tokens, refresh),
apiFetch("/targets/owner-candidates", {}, tokens, refresh),
]);
setTargets(targetRows);
setOwnerCandidates(candidates);
} else {
setTargets(await apiFetch("/targets", {}, tokens, refresh));
}
setError("");
} catch (e) {
setError(String(e.message || e));
} finally {
setLoading(false);
}
};
useEffect(() => {
load();
}, [canManage]);
const createTarget = async (e) => {
e.preventDefault();
try {
await apiFetch("/targets", { method: "POST", body: JSON.stringify(form) }, tokens, refresh);
setForm(emptyForm);
await load();
} catch (e) {
setError(String(e.message || e));
}
};
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 {
await apiFetch(`/targets/${id}`, { method: "DELETE" }, tokens, refresh);
await load();
} catch (e) {
setError(String(e.message || e));
}
};
const startEdit = (target) => {
setEditing(true);
setSaveState({ loading: false, message: "" });
setEditOwnerQuery("");
setEditForm({
id: target.id,
name: target.name,
host: target.host,
port: target.port,
dbname: target.dbname,
username: target.username,
password: "",
sslmode: target.sslmode,
use_pg_stat_statements: target.use_pg_stat_statements !== false,
owner_user_ids: Array.isArray(target.owner_user_ids) ? target.owner_user_ids : [],
});
};
const cancelEdit = () => {
setEditing(false);
setEditForm(emptyEditForm);
setSaveState({ loading: false, message: "" });
};
const saveEdit = async (e) => {
e.preventDefault();
if (!editForm.id) return;
setSaveState({ loading: true, message: "" });
try {
const payload = {
name: editForm.name,
host: editForm.host,
port: Number(editForm.port),
dbname: editForm.dbname,
username: editForm.username,
sslmode: editForm.sslmode,
use_pg_stat_statements: !!editForm.use_pg_stat_statements,
owner_user_ids: editForm.owner_user_ids || [],
};
if (editForm.password.trim()) payload.password = editForm.password;
await apiFetch(`/targets/${editForm.id}`, { method: "PUT", body: JSON.stringify(payload) }, tokens, refresh);
setSaveState({ loading: false, message: "Target updated." });
setEditing(false);
setEditForm(emptyEditForm);
await load();
} catch (e) {
setSaveState({ loading: false, message: String(e.message || e) });
}
};
return (
Targets Management
{error &&
{error}
}
{canManage && (
New Target
v
{testState.message && (
{testState.message}
)}
)}
{canManage && editing && (
Edit Target
{saveState.message && {saveState.message}
}
)}
{canManage && (
Troubleshooting
Quick checks for the most common connection issues.
v
Connection refused: host/port is wrong or database is unreachable.
rejected SSL upgrade: set SSL mode to disable.
localhost points to the backend container itself, not your host machine.
)}
{loading ? (
Loading targets...
) : (
| Name |
Host |
DB |
Owners |
Query Insights |
Actions |
{targets.map((t) => (
| {t.name} |
{t.host}:{t.port} |
{t.dbname} |
{Array.isArray(t.owner_user_ids) ? t.owner_user_ids.length : 0} |
{t.use_pg_stat_statements ? "Enabled" : "Disabled"}
|
Details
{canManage && (
)}
{canManage && (
)}
|
))}
)}
);
}