Files
NexaPG/frontend/src/pages/TargetsPage.jsx
nessi fa8958934f
Some checks are pending
PostgreSQL Compatibility Matrix / PG14 smoke (push) Waiting to run
PostgreSQL Compatibility Matrix / PG15 smoke (push) Successful in 28s
PostgreSQL Compatibility Matrix / PG16 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG17 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG18 smoke (push) Successful in 6s
Add multi-database discovery and grouping features
This update introduces optional automatic discovery and onboarding of all databases on a PostgreSQL instance. It also enhances the frontend UI with grouped target display and navigation, making it easier to view and manage related databases. Additionally, new backend endpoints and logic ensure seamless integration of these features.
2026-02-12 16:54:22 +01:00

530 lines
20 KiB
JavaScript

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 (
<div className="owner-picker" ref={pickerRef}>
<div className="owner-selected">
{selected.length > 0 ? (
selected.map((item) => (
<button
key={`selected-${item.user_id}`}
type="button"
className="owner-selected-chip"
onClick={() => onToggle(item.user_id)}
title="Remove owner"
>
<span>{item.email}</span>
<span aria-hidden="true">x</span>
</button>
))
) : (
<span className="muted">No owners selected yet.</span>
)}
</div>
<div className={`owner-search-shell ${open ? "open" : ""}`}>
<input
type="text"
className="owner-search-input"
value={query}
onChange={(e) => onQueryChange(e.target.value)}
onFocus={() => setOpen(true)}
onClick={() => setOpen(true)}
placeholder="Search users by email..."
/>
<button type="button" className="owner-search-toggle" onClick={() => setOpen((prev) => !prev)}>
v
</button>
</div>
{open && (
<div className="owner-dropdown">
<div className="owner-search-results">
{filtered.map((item) => {
const active = selectedIds.includes(item.user_id);
return (
<button
key={item.user_id}
type="button"
className={`owner-result ${active ? "active" : ""}`}
onClick={() => onToggle(item.user_id)}
>
<span>{item.email}</span>
<small>{item.role}</small>
</button>
);
})}
{filtered.length === 0 && <div className="owner-result-empty">No matching users.</div>}
</div>
</div>
)}
</div>
);
}
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 (
<div className="targets-page">
<h2>Targets Management</h2>
{error && <div className="card error">{error}</div>}
{canManage && (
<details className="card collapsible">
<summary className="collapse-head">
<div>
<h3>New Target</h3>
</div>
<span className="collapse-chevron" aria-hidden="true">v</span>
</summary>
<form className="target-form grid two" onSubmit={createTarget}>
<div className="field">
<label>Name</label>
<input placeholder="e.g. Prod-DB" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} required />
</div>
<div className="field">
<label>Host</label>
<input placeholder="e.g. 172.16.0.106 or db.internal" value={form.host} onChange={(e) => setForm({ ...form, host: e.target.value })} required />
</div>
<div className="field">
<label>Port</label>
<input placeholder="5432" value={form.port} onChange={(e) => setForm({ ...form, port: Number(e.target.value) })} type="number" required />
</div>
<div className="field">
<label>{form.discover_all_databases ? "Discovery DB" : "DB Name"}</label>
<input
placeholder={form.discover_all_databases ? "e.g. postgres" : "e.g. postgres or appdb"}
value={form.dbname}
onChange={(e) => setForm({ ...form, dbname: e.target.value })}
required
/>
<small>
{form.discover_all_databases
? "Connection database used to crawl all available databases on this instance."
: "Single database to monitor for this target."}
</small>
</div>
<div className="field">
<label>Username</label>
<input placeholder="e.g. postgres" value={form.username} onChange={(e) => setForm({ ...form, username: e.target.value })} required />
</div>
<div className="field">
<label>Password</label>
<input placeholder="Password" type="password" value={form.password} onChange={(e) => setForm({ ...form, password: e.target.value })} required />
</div>
<div className="field">
<label>SSL Mode</label>
<select value={form.sslmode} onChange={(e) => setForm({ ...form, sslmode: e.target.value })}>
<option value="disable">disable</option>
<option value="prefer">prefer</option>
<option value="require">require</option>
</select>
<small>
If you see "rejected SSL upgrade", switch to <code>disable</code>.
</small>
</div>
<div className="field toggle-field">
<label>Query Insights Source</label>
<label className="toggle-check">
<input
type="checkbox"
checked={!!form.use_pg_stat_statements}
onChange={(e) => setForm({ ...form, use_pg_stat_statements: e.target.checked })}
/>
<span className="toggle-ui" aria-hidden="true" />
<span className="toggle-copy">
<strong>Use pg_stat_statements for this target</strong>
<small>Disable this if the extension is unavailable on the target.</small>
</span>
</label>
</div>
<div className="field toggle-field">
<label>Scope</label>
<label className="toggle-check">
<input
type="checkbox"
checked={!!form.discover_all_databases}
onChange={(e) => setForm({ ...form, discover_all_databases: e.target.checked })}
/>
<span className="toggle-ui" aria-hidden="true" />
<span className="toggle-copy">
<strong>Discover and add all databases</strong>
<small>Requires credentials with access to list databases (typically a superuser).</small>
</span>
</label>
</div>
<div className="field field-full">
<label>Responsible Users (Target Owners)</label>
<OwnerPicker
candidates={ownerCandidates}
selectedIds={form.owner_user_ids}
query={createOwnerQuery}
onQueryChange={setCreateOwnerQuery}
onToggle={(userId) => setForm({ ...form, owner_user_ids: toggleOwner(form.owner_user_ids, userId) })}
/>
<small>Only selected users will receive email notifications for this target's alerts.</small>
</div>
<div className="field submit-field field-full">
<div className="form-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>
</form>
{testState.message && (
<div className={`test-connection-result ${testState.ok ? "ok" : "fail"}`}>{testState.message}</div>
)}
</details>
)}
{canManage && editing && (
<section className="card">
<h3>Edit Target</h3>
<form className="target-form grid two" onSubmit={saveEdit}>
<div className="field">
<label>Name</label>
<input value={editForm.name} onChange={(e) => setEditForm({ ...editForm, name: e.target.value })} required />
</div>
<div className="field">
<label>Host</label>
<input value={editForm.host} onChange={(e) => setEditForm({ ...editForm, host: e.target.value })} required />
</div>
<div className="field">
<label>Port</label>
<input type="number" value={editForm.port} onChange={(e) => setEditForm({ ...editForm, port: Number(e.target.value) })} required />
</div>
<div className="field">
<label>DB Name</label>
<input value={editForm.dbname} onChange={(e) => setEditForm({ ...editForm, dbname: e.target.value })} required />
</div>
<div className="field">
<label>Username</label>
<input value={editForm.username} onChange={(e) => setEditForm({ ...editForm, username: e.target.value })} required />
</div>
<div className="field">
<label>New Password (optional)</label>
<input type="password" placeholder="Leave empty to keep current" value={editForm.password} onChange={(e) => setEditForm({ ...editForm, password: e.target.value })} />
</div>
<div className="field">
<label>SSL Mode</label>
<select value={editForm.sslmode} onChange={(e) => setEditForm({ ...editForm, sslmode: e.target.value })}>
<option value="disable">disable</option>
<option value="prefer">prefer</option>
<option value="require">require</option>
</select>
</div>
<div className="field toggle-field">
<label>Query Insights Source</label>
<label className="toggle-check">
<input
type="checkbox"
checked={!!editForm.use_pg_stat_statements}
onChange={(e) => setEditForm({ ...editForm, use_pg_stat_statements: e.target.checked })}
/>
<span className="toggle-ui" aria-hidden="true" />
<span className="toggle-copy">
<strong>Use pg_stat_statements for this target</strong>
<small>Disable this if the extension is unavailable on the target.</small>
</span>
</label>
</div>
<div className="field field-full">
<label>Responsible Users (Target Owners)</label>
<OwnerPicker
candidates={ownerCandidates}
selectedIds={editForm.owner_user_ids}
query={editOwnerQuery}
onQueryChange={setEditOwnerQuery}
onToggle={(userId) => setEditForm({ ...editForm, owner_user_ids: toggleOwner(editForm.owner_user_ids, userId) })}
/>
<small>Only selected users will receive email notifications for this target's alerts.</small>
</div>
<div className="field submit-field field-full">
<div className="form-actions">
<button type="button" className="secondary-btn" onClick={cancelEdit}>Cancel</button>
<button className="primary-btn" disabled={saveState.loading}>{saveState.loading ? "Saving..." : "Save changes"}</button>
</div>
</div>
</form>
{saveState.message && <div className="test-connection-result">{saveState.message}</div>}
</section>
)}
{canManage && (
<details className="card collapsible tips">
<summary className="collapse-head">
<div>
<h3>Troubleshooting</h3>
<p>Quick checks for the most common connection issues.</p>
</div>
<span className="collapse-chevron" aria-hidden="true">v</span>
</summary>
<p>
<code>Connection refused</code>: host/port is wrong or database is unreachable.
</p>
<p>
<code>rejected SSL upgrade</code>: set SSL mode to <code>disable</code>.
</p>
<p>
<code>localhost</code> points to the backend container itself, not your host machine.
</p>
</details>
)}
<div className="card targets-table">
{loading ? (
<p>Loading targets...</p>
) : (
<table>
<thead>
<tr>
<th>Name</th>
<th>Host</th>
<th>DB</th>
<th>Owners</th>
<th>Query Insights</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{targets.map((t) => (
<tr key={t.id}>
<td>{t.name}</td>
<td>{t.host}:{t.port}</td>
<td>{t.dbname}</td>
<td>{Array.isArray(t.owner_user_ids) ? t.owner_user_ids.length : 0}</td>
<td>
<span className={`status-chip ${t.use_pg_stat_statements ? "ok" : "warning"}`}>
{t.use_pg_stat_statements ? "Enabled" : "Disabled"}
</span>
</td>
<td>
<div className="row-actions">
<Link className="table-action-btn details" to={`/targets/${t.id}`}>
<span aria-hidden="true">
<svg viewBox="0 0 24 24" width="13" height="13">
<path d="M2 12s3.5-6 10-6 10 6 10 6-3.5 6-10 6-10-6-10-6zm10 3a3 3 0 1 0 0-6 3 3 0 0 0 0 6z" fill="currentColor" />
</svg>
</span>
Details
</Link>
{canManage && (
<button className="table-action-btn edit" onClick={() => startEdit(t)}>
<span aria-hidden="true">
<svg viewBox="0 0 24 24" width="13" height="13">
<path d="M3 17.25V21h3.75L19.81 7.94l-3.75-3.75L3 17.25zm2.92 2.33H5v-.92l10.06-10.06.92.92L5.92 19.58zM20.71 6.04a1 1 0 0 0 0-1.41L19.37 3.3a1 1 0 0 0-1.41 0l-1.13 1.12 3.75 3.75 1.13-1.13z" fill="currentColor" />
</svg>
</span>
Edit
</button>
)}
{canManage && (
<button className="table-action-btn delete" onClick={() => deleteTarget(t.id)}>
<span aria-hidden="true">
<svg viewBox="0 0 24 24" width="13" height="13">
<path d="M6 7h12l-1 13H7L6 7zm3-3h6l1 2H8l1-2z" fill="currentColor" />
</svg>
</span>
Delete
</button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
);
}