Files
NexaPG/frontend/src/pages/TargetsPage.jsx
nessi a8b7d9f54a Enhance toggle fields styling and layout
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.
2026-02-12 13:50:03 +01:00

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>
);
}