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:
2026-02-12 13:39:57 +01:00
parent 839943d9fd
commit 712bec3fea
8 changed files with 215 additions and 15 deletions

View File

@@ -0,0 +1,26 @@
"""add target pg_stat_statements flag
Revision ID: 0003_target_pg_stat_statements_flag
Revises: 0002_alert_definitions
Create Date: 2026-02-12
"""
from alembic import op
import sqlalchemy as sa
revision = "0003_target_pg_stat_statements_flag"
down_revision = "0002_alert_definitions"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"targets",
sa.Column("use_pg_stat_statements", sa.Boolean(), nullable=False, server_default=sa.text("true")),
)
def downgrade() -> None:
op.drop_column("targets", "use_pg_stat_statements")

View File

@@ -64,6 +64,7 @@ async def create_target(
username=payload.username, username=payload.username,
encrypted_password=encrypt_secret(payload.password), encrypted_password=encrypt_secret(payload.password),
sslmode=payload.sslmode, sslmode=payload.sslmode,
use_pg_stat_statements=payload.use_pg_stat_statements,
tags=payload.tags, tags=payload.tags,
) )
db.add(target) db.add(target)
@@ -188,6 +189,11 @@ async def get_activity(target_id: int, user: User = Depends(get_current_user), d
@router.get("/{target_id}/top-queries", response_model=list[QueryStatOut]) @router.get("/{target_id}/top-queries", response_model=list[QueryStatOut])
async def get_top_queries(target_id: int, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)) -> list[QueryStatOut]: async def get_top_queries(target_id: int, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)) -> list[QueryStatOut]:
_ = user _ = user
target = await db.scalar(select(Target).where(Target.id == target_id))
if not target:
raise HTTPException(status_code=404, detail="Target not found")
if not target.use_pg_stat_statements:
return []
rows = ( rows = (
await db.scalars( await db.scalars(
select(QueryStat) select(QueryStat)

View File

@@ -27,6 +27,7 @@ class Target(Base):
username: Mapped[str] = mapped_column(String(120), nullable=False) username: Mapped[str] = mapped_column(String(120), nullable=False)
encrypted_password: Mapped[str] = mapped_column(Text, nullable=False) encrypted_password: Mapped[str] = mapped_column(Text, nullable=False)
sslmode: Mapped[str] = mapped_column(String(20), nullable=False, default="prefer") sslmode: Mapped[str] = mapped_column(String(20), nullable=False, default="prefer")
use_pg_stat_statements: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
tags: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict) tags: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)

View File

@@ -9,6 +9,7 @@ class TargetBase(BaseModel):
dbname: str dbname: str
username: str username: str
sslmode: str = "prefer" sslmode: str = "prefer"
use_pg_stat_statements: bool = True
tags: dict = Field(default_factory=dict) tags: dict = Field(default_factory=dict)
@@ -33,6 +34,7 @@ class TargetUpdate(BaseModel):
username: str | None = None username: str | None = None
password: str | None = None password: str | None = None
sslmode: str | None = None sslmode: str | None = None
use_pg_stat_statements: bool | None = None
tags: dict | None = None tags: dict | None = None

View File

@@ -106,18 +106,19 @@ async def collect_target(target: Target) -> None:
cache_hit_ratio = stat_db["blks_hit"] / (stat_db["blks_hit"] + stat_db["blks_read"]) cache_hit_ratio = stat_db["blks_hit"] / (stat_db["blks_hit"] + stat_db["blks_read"])
query_rows = [] query_rows = []
try: if target.use_pg_stat_statements:
query_rows = await conn.fetch( try:
""" query_rows = await conn.fetch(
SELECT queryid::text, calls, total_exec_time, mean_exec_time, rows, left(query, 2000) AS query_text """
FROM pg_stat_statements SELECT queryid::text, calls, total_exec_time, mean_exec_time, rows, left(query, 2000) AS query_text
ORDER BY total_exec_time DESC FROM pg_stat_statements
LIMIT 20 ORDER BY total_exec_time DESC
""" LIMIT 20
) """
except Exception: )
# Extension may be disabled on monitored instance. except Exception:
query_rows = [] # Extension may be disabled on monitored instance.
query_rows = []
async with SessionLocal() as db: async with SessionLocal() as db:
await _store_metric(db, target.id, "connections_total", activity["total_connections"], {}) await _store_metric(db, target.id, "connections_total", activity["total_connections"], {})

View File

@@ -75,8 +75,10 @@ export function QueryInsightsPage() {
(async () => { (async () => {
try { try {
const t = await apiFetch("/targets", {}, tokens, refresh); const t = await apiFetch("/targets", {}, tokens, refresh);
setTargets(t); const supported = t.filter((item) => item.use_pg_stat_statements !== false);
if (t.length > 0) setTargetId(String(t[0].id)); setTargets(supported);
if (supported.length > 0) setTargetId(String(supported[0].id));
else setTargetId("");
} catch (e) { } catch (e) {
setError(String(e.message || e)); setError(String(e.message || e));
} finally { } finally {
@@ -133,11 +135,17 @@ export function QueryInsightsPage() {
<div className="query-insights-page"> <div className="query-insights-page">
<h2>Query Insights</h2> <h2>Query Insights</h2>
<p>Note: This section requires the <code>pg_stat_statements</code> extension on the monitored target.</p> <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>} {error && <div className="card error">{error}</div>}
<div className="card query-toolbar"> <div className="card query-toolbar">
<div className="field"> <div className="field">
<label>Target</label> <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) => ( {targets.map((t) => (
<option key={t.id} value={t.id}> <option key={t.id} value={t.id}>
{t.name} {t.name}

View File

@@ -11,16 +11,32 @@ const emptyForm = {
username: "", username: "",
password: "", password: "",
sslmode: "prefer", sslmode: "prefer",
use_pg_stat_statements: true,
tags: {}, tags: {},
}; };
const emptyEditForm = {
id: null,
name: "",
host: "",
port: 5432,
dbname: "",
username: "",
password: "",
sslmode: "prefer",
use_pg_stat_statements: true,
};
export function TargetsPage() { export function TargetsPage() {
const { tokens, refresh, me } = useAuth(); const { tokens, refresh, me } = useAuth();
const [targets, setTargets] = useState([]); const [targets, setTargets] = useState([]);
const [form, setForm] = useState(emptyForm); const [form, setForm] = useState(emptyForm);
const [editForm, setEditForm] = useState(emptyEditForm);
const [editing, setEditing] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [testState, setTestState] = useState({ loading: false, message: "", ok: null }); 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 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 ( return (
<div className="targets-page"> <div className="targets-page">
<h2>Targets Management</h2> <h2>Targets Management</h2>
@@ -136,6 +200,18 @@ export function TargetsPage() {
If you see "rejected SSL upgrade", switch to <code>disable</code>. If you see "rejected SSL upgrade", switch to <code>disable</code>.
</small> </small>
</div> </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="field submit-field">
<div className="form-actions"> <div className="form-actions">
<button type="button" className="secondary-btn" onClick={testConnection} disabled={testState.loading}> <button type="button" className="secondary-btn" onClick={testConnection} disabled={testState.loading}>
@@ -151,6 +227,64 @@ export function TargetsPage() {
</details> </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 && ( {canManage && (
<details className="card collapsible tips"> <details className="card collapsible tips">
<summary className="collapse-head"> <summary className="collapse-head">
@@ -182,6 +316,7 @@ export function TargetsPage() {
<th>Name</th> <th>Name</th>
<th>Host</th> <th>Host</th>
<th>DB</th> <th>DB</th>
<th>Query Insights</th>
<th>Actions</th> <th>Actions</th>
</tr> </tr>
</thead> </thead>
@@ -191,8 +326,14 @@ export function TargetsPage() {
<td>{t.name}</td> <td>{t.name}</td>
<td>{t.host}:{t.port}</td> <td>{t.host}:{t.port}</td>
<td>{t.dbname}</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> <td>
<Link className="table-link" to={`/targets/${t.id}`}>Details</Link>{" "} <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>} {canManage && <button className="danger-btn" onClick={() => deleteTarget(t.id)}>Delete</button>}
</td> </td>
</tr> </tr>

View File

@@ -249,6 +249,21 @@ button {
color: #8fa0bf; 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 { .tips p {
margin: 8px 0; margin: 8px 0;
color: #bfd0ea; color: #bfd0ea;