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.
308 lines
12 KiB
JavaScript
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>
|
|
);
|
|
}
|