Files
NexaPG/frontend/src/pages/QueryInsightsPage.jsx
nessi 5c5d51350f Fix stale refresh usage in QueryInsightsPage effect hooks
Replaced `refresh` with `useRef` to ensure the latest value is always used in async operations. Added cleanup logic to handle component unmounts during API calls, preventing state updates on unmounted components.
2026-02-13 10:06:56 +01:00

308 lines
12 KiB
JavaScript

import React, { useEffect, useRef, useState } from "react";
import { apiFetch } from "../api";
import { useAuth } from "../state";
const PAGE_SIZE = 10;
function scoreQuery(row) {
const mean = Number(row.mean_time || 0);
const calls = Number(row.calls || 0);
const total = Number(row.total_time || 0);
const rows = Number(row.rows || 0);
return mean * 1.4 + total * 0.9 + calls * 0.2 + Math.min(rows / 50, 25);
}
function classifyQuery(row) {
if ((row.mean_time || 0) > 100) return { label: "Very Slow", kind: "danger" };
if ((row.total_time || 0) > 250) return { label: "Heavy", kind: "warn" };
if ((row.calls || 0) > 500) return { label: "Frequent", kind: "info" };
return { label: "Normal", kind: "ok" };
}
function compactSql(sql) {
if (!sql) return "-";
return sql.replace(/\s+/g, " ").trim();
}
function buildQueryTips(row) {
if (!row) return [];
const tips = [];
const sql = (row.query_text || "").toLowerCase();
if ((row.mean_time || 0) > 100) {
tips.push("High latency per call: inspect execution plan with EXPLAIN (ANALYZE, BUFFERS).");
}
if ((row.total_time || 0) > 500) {
tips.push("High cumulative runtime: optimize this query first for fastest overall impact.");
}
if ((row.calls || 0) > 500) {
tips.push("Very frequent query: consider caching or reducing call frequency in application code.");
}
if ((row.rows || 0) > 100000) {
tips.push("Large row output: return fewer columns/rows or add pagination at query/API layer.");
}
if (/select\s+\*/.test(sql)) {
tips.push("Avoid SELECT *: request only required columns to reduce IO and transfer cost.");
}
if (/order\s+by/.test(sql) && !/limit\s+\d+/.test(sql)) {
tips.push("ORDER BY without LIMIT can be expensive: add LIMIT where possible.");
}
if (/where/.test(sql) && / from /.test(sql)) {
tips.push("Filter query detected: verify indexes exist on WHERE / JOIN columns.");
}
if (/like\s+'%/.test(sql)) {
tips.push("Leading wildcard LIKE can bypass indexes: consider trigram index (pg_trgm).");
}
if (tips.length === 0) {
tips.push("No obvious anti-pattern detected. Validate with EXPLAIN and index usage statistics.");
}
return tips.slice(0, 5);
}
export function QueryInsightsPage() {
const { tokens, refresh } = useAuth();
const refreshRef = useRef(refresh);
const [targets, setTargets] = useState([]);
const [targetId, setTargetId] = useState("");
const [rows, setRows] = useState([]);
const [selectedQuery, setSelectedQuery] = useState(null);
const [search, setSearch] = useState("");
const [page, setPage] = useState(1);
const [error, setError] = useState("");
const [loading, setLoading] = useState(true);
useEffect(() => {
refreshRef.current = refresh;
}, [refresh]);
useEffect(() => {
(async () => {
try {
const t = await apiFetch("/targets", {}, tokens, refresh);
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 {
setLoading(false);
}
})();
}, []);
useEffect(() => {
if (!targetId) return;
let active = true;
(async () => {
try {
const data = await apiFetch(`/targets/${targetId}/top-queries`, {}, tokens, refreshRef.current);
if (!active) return;
setRows(data);
setSelectedQuery((prev) => {
if (!prev) return data[0] || null;
const keep = data.find((row) => row.queryid === prev.queryid);
return keep || data[0] || null;
});
setPage((prev) => (prev === 1 ? prev : 1));
} catch (e) {
if (active) setError(String(e.message || e));
}
})();
return () => {
active = false;
};
}, [targetId, tokens?.accessToken, tokens?.refreshToken]);
const dedupedByQueryId = [...rows].reduce((acc, row) => {
if (!row?.queryid) return acc;
if (!acc[row.queryid]) acc[row.queryid] = row;
return acc;
}, {});
const uniqueRows = Object.values(dedupedByQueryId);
const sorted = [...uniqueRows].sort((a, b) => (b.total_time || 0) - (a.total_time || 0));
const byMean = [...uniqueRows].sort((a, b) => (b.mean_time || 0) - (a.mean_time || 0));
const byCalls = [...uniqueRows].sort((a, b) => (b.calls || 0) - (a.calls || 0));
const byRows = [...uniqueRows].sort((a, b) => (b.rows || 0) - (a.rows || 0));
const byPriority = [...uniqueRows].sort((a, b) => scoreQuery(b) - scoreQuery(a));
const filtered = byPriority.filter((r) => {
if (!search.trim()) return true;
return compactSql(r.query_text).toLowerCase().includes(search.toLowerCase());
});
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
const safePage = Math.min(page, totalPages);
const paged = filtered.slice((safePage - 1) * PAGE_SIZE, safePage * PAGE_SIZE);
const selectedTips = buildQueryTips(selectedQuery);
const categories = [
{ key: "priority", title: "Optimization Priority", row: byPriority[0], subtitle: "Best first candidate to optimize" },
{ key: "total", title: "Longest Total Time", row: sorted[0], subtitle: "Biggest cumulative runtime impact" },
{ key: "mean", title: "Highest Mean Time", row: byMean[0], subtitle: "Slowest single-call latency" },
{ key: "calls", title: "Most Frequent", row: byCalls[0], subtitle: "Executed very often" },
{ key: "rows", title: "Most Rows Returned", row: byRows[0], subtitle: "Potentially heavy scans" },
];
return (
<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)} disabled={!targets.length}>
{targets.map((t) => (
<option key={t.id} value={t.id}>
{t.name}
</option>
))}
</select>
</div>
<div className="field">
<label>Search Query Text</label>
<input
value={search}
onChange={(e) => {
setSearch(e.target.value);
setPage(1);
}}
placeholder="e.g. select * from users"
/>
</div>
</div>
{loading ? (
<div className="card">Loading query insights...</div>
) : (
<>
<div className="grid three">
{categories.map((item) => {
const r = item.row;
const state = r ? classifyQuery(r) : null;
return (
<div
className="card query-category"
key={item.key}
onClick={() => r && setSelectedQuery(r)}
role="button"
tabIndex={0}
>
<h4>{item.title}</h4>
<small>{item.subtitle}</small>
{r ? (
<>
<div className={`query-state ${state.kind}`}>{state.label}</div>
<div className="query-kpi">
<span>Total</span>
<strong>{Number(r.total_time || 0).toFixed(2)} ms</strong>
</div>
<div className="query-kpi">
<span>Mean</span>
<strong>{Number(r.mean_time || 0).toFixed(2)} ms</strong>
</div>
<div className="query-kpi">
<span>Calls</span>
<strong>{r.calls}</strong>
</div>
</>
) : (
<p>No data</p>
)}
</div>
);
})}
</div>
<div className="grid two">
<div className="card query-list">
<h3>Ranked Queries</h3>
<table>
<thead>
<tr>
<th>Priority</th>
<th>Calls</th>
<th>Total ms</th>
<th>Mean ms</th>
<th>Rows</th>
<th>Query Preview</th>
</tr>
</thead>
<tbody>
{paged.map((r, i) => {
const state = classifyQuery(r);
return (
<tr key={`${r.queryid}-${i}-${safePage}`} className={selectedQuery?.queryid === r.queryid ? "active-row" : ""}>
<td><span className={`query-state ${state.kind}`}>{state.label}</span></td>
<td>{r.calls}</td>
<td>{Number(r.total_time || 0).toFixed(2)}</td>
<td>{Number(r.mean_time || 0).toFixed(2)}</td>
<td>{r.rows}</td>
<td className="query">
<button className="table-link" onClick={() => setSelectedQuery(r)}>
{compactSql(r.query_text)}
</button>
</td>
</tr>
);
})}
</tbody>
</table>
<div className="pagination">
<span className="pagination-info">
Showing {paged.length} of {filtered.length} queries
</span>
<div className="pagination-actions">
<button type="button" disabled={safePage <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>
Prev
</button>
<span>Page {safePage} / {totalPages}</span>
<button type="button" disabled={safePage >= totalPages} onClick={() => setPage((p) => Math.min(totalPages, p + 1))}>
Next
</button>
</div>
</div>
</div>
<div className="card query-detail">
<h3>Selected Query</h3>
{selectedQuery ? (
<>
<div className="overview-metrics">
<div><span>Calls</span><strong>{selectedQuery.calls}</strong></div>
<div><span>Total Time</span><strong>{Number(selectedQuery.total_time || 0).toFixed(2)} ms</strong></div>
<div><span>Mean Time</span><strong>{Number(selectedQuery.mean_time || 0).toFixed(2)} ms</strong></div>
<div><span>Rows</span><strong>{selectedQuery.rows}</strong></div>
</div>
<div className="sql-block">
<code>{selectedQuery.query_text || "-- no query text available --"}</code>
</div>
<div className="query-tips">
<h4>Optimization Suggestions</h4>
<ul>
{selectedTips.map((tip, idx) => <li key={idx}>{tip}</li>)}
</ul>
</div>
<p className="query-hint">
Tip: focus first on queries with high <strong>Total Time</strong> (overall impact) and high <strong>Mean Time</strong> (latency hotspots).
</p>
</>
) : (
<p>No query selected.</p>
)}
</div>
</div>
</>
)}
</div>
);
}