Updated the "Query Insights Source" toggle components for better UI consistency and accessibility. Added new styles for toggle switches and improved layout alignment to ensure a cohesive design throughout the page.
355 lines
13 KiB
JavaScript
355 lines
13 KiB
JavaScript
import React, { useEffect, useState } from "react";
|
|
import { Link } from "react-router-dom";
|
|
import { apiFetch } from "../api";
|
|
import { useAuth } from "../state";
|
|
|
|
const emptyForm = {
|
|
name: "",
|
|
host: "",
|
|
port: 5432,
|
|
dbname: "",
|
|
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";
|
|
|
|
const load = async () => {
|
|
setLoading(true);
|
|
try {
|
|
setTargets(await apiFetch("/targets", {}, tokens, refresh));
|
|
setError("");
|
|
} catch (e) {
|
|
setError(String(e.message || e));
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
load();
|
|
}, []);
|
|
|
|
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: "" });
|
|
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>
|
|
{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>DB Name</label>
|
|
<input placeholder="e.g. postgres or appdb" value={form.dbname} onChange={(e) => setForm({ ...form, dbname: e.target.value })} required />
|
|
</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 field-full 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 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 field-full 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 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>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>
|
|
<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>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|