Add support for pg_stat_statements configuration in Targets
This commit introduces a `use_pg_stat_statements` flag for targets, allowing users to enable or disable the use of `pg_stat_statements` for query insights. It includes database schema changes, backend logic, and UI updates to manage this setting in both creation and editing workflows.
This commit is contained in:
@@ -75,8 +75,10 @@ export function QueryInsightsPage() {
|
||||
(async () => {
|
||||
try {
|
||||
const t = await apiFetch("/targets", {}, tokens, refresh);
|
||||
setTargets(t);
|
||||
if (t.length > 0) setTargetId(String(t[0].id));
|
||||
const supported = t.filter((item) => item.use_pg_stat_statements !== false);
|
||||
setTargets(supported);
|
||||
if (supported.length > 0) setTargetId(String(supported[0].id));
|
||||
else setTargetId("");
|
||||
} catch (e) {
|
||||
setError(String(e.message || e));
|
||||
} finally {
|
||||
@@ -133,11 +135,17 @@ export function QueryInsightsPage() {
|
||||
<div className="query-insights-page">
|
||||
<h2>Query Insights</h2>
|
||||
<p>Note: This section requires the <code>pg_stat_statements</code> extension on the monitored target.</p>
|
||||
{targets.length === 0 && !loading && (
|
||||
<div className="card">
|
||||
No targets with enabled <code>pg_stat_statements</code> are available.
|
||||
Enable it in <strong>Targets Management</strong> for a target to use Query Insights.
|
||||
</div>
|
||||
)}
|
||||
{error && <div className="card error">{error}</div>}
|
||||
<div className="card query-toolbar">
|
||||
<div className="field">
|
||||
<label>Target</label>
|
||||
<select value={targetId} onChange={(e) => setTargetId(e.target.value)}>
|
||||
<select value={targetId} onChange={(e) => setTargetId(e.target.value)} disabled={!targets.length}>
|
||||
{targets.map((t) => (
|
||||
<option key={t.id} value={t.id}>
|
||||
{t.name}
|
||||
|
||||
@@ -11,16 +11,32 @@ const emptyForm = {
|
||||
username: "",
|
||||
password: "",
|
||||
sslmode: "prefer",
|
||||
use_pg_stat_statements: true,
|
||||
tags: {},
|
||||
};
|
||||
|
||||
const emptyEditForm = {
|
||||
id: null,
|
||||
name: "",
|
||||
host: "",
|
||||
port: 5432,
|
||||
dbname: "",
|
||||
username: "",
|
||||
password: "",
|
||||
sslmode: "prefer",
|
||||
use_pg_stat_statements: true,
|
||||
};
|
||||
|
||||
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 canManage = me?.role === "admin" || me?.role === "operator";
|
||||
|
||||
@@ -86,6 +102,54 @@ export function TargetsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const startEdit = (target) => {
|
||||
setEditing(true);
|
||||
setSaveState({ loading: false, message: "" });
|
||||
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,
|
||||
});
|
||||
};
|
||||
|
||||
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,
|
||||
};
|
||||
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>
|
||||
@@ -136,6 +200,18 @@ export function TargetsPage() {
|
||||
If you see "rejected SSL upgrade", switch to <code>disable</code>.
|
||||
</small>
|
||||
</div>
|
||||
<div className="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>Use pg_stat_statements for this target</span>
|
||||
</label>
|
||||
<small>Disable this if the extension is unavailable on the target.</small>
|
||||
</div>
|
||||
<div className="field submit-field">
|
||||
<div className="form-actions">
|
||||
<button type="button" className="secondary-btn" onClick={testConnection} disabled={testState.loading}>
|
||||
@@ -151,6 +227,64 @@ export function TargetsPage() {
|
||||
</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">
|
||||
<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>Use pg_stat_statements for this target</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="field submit-field">
|
||||
<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">
|
||||
@@ -182,6 +316,7 @@ export function TargetsPage() {
|
||||
<th>Name</th>
|
||||
<th>Host</th>
|
||||
<th>DB</th>
|
||||
<th>Query Insights</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -191,8 +326,14 @@ export function TargetsPage() {
|
||||
<td>{t.name}</td>
|
||||
<td>{t.host}:{t.port}</td>
|
||||
<td>{t.dbname}</td>
|
||||
<td>
|
||||
<span className={`status-chip ${t.use_pg_stat_statements ? "ok" : "warning"}`}>
|
||||
{t.use_pg_stat_statements ? "Enabled" : "Disabled"}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<Link className="table-link" to={`/targets/${t.id}`}>Details</Link>{" "}
|
||||
{canManage && <button className="secondary-btn small-btn" onClick={() => startEdit(t)}>Edit</button>}
|
||||
{canManage && <button className="danger-btn" onClick={() => deleteTarget(t.id)}>Delete</button>}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -249,6 +249,21 @@ button {
|
||||
color: #8fa0bf;
|
||||
}
|
||||
|
||||
.toggle-check {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
color: #d7e6fb;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.toggle-check input[type="checkbox"] {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
accent-color: #27bbf3;
|
||||
}
|
||||
|
||||
.tips p {
|
||||
margin: 8px 0;
|
||||
color: #bfd0ea;
|
||||
|
||||
Reference in New Issue
Block a user