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:
@@ -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")
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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"], {})
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user