Add search, pagination, and query tips to Query Insights.
This update introduces a search input for filtering queries and a pagination system for easier navigation of results. Query tips now provide optimization suggestions based on detected patterns and statistics, helping users identify and address performance issues more effectively. Styling adjustments were also made to improve the layout and user experience.
This commit is contained in:
@@ -2,6 +2,8 @@ import React, { useEffect, 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);
|
||||
@@ -22,12 +24,50 @@ function compactSql(sql) {
|
||||
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 [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);
|
||||
|
||||
@@ -52,17 +92,34 @@ export function QueryInsightsPage() {
|
||||
const data = await apiFetch(`/targets/${targetId}/top-queries`, {}, tokens, refresh);
|
||||
setRows(data);
|
||||
setSelectedQuery(data[0] || null);
|
||||
setPage(1);
|
||||
} catch (e) {
|
||||
setError(String(e.message || e));
|
||||
}
|
||||
})();
|
||||
}, [targetId, tokens, refresh]);
|
||||
|
||||
const sorted = [...rows].sort((a, b) => (b.total_time || 0) - (a.total_time || 0));
|
||||
const byMean = [...rows].sort((a, b) => (b.mean_time || 0) - (a.mean_time || 0));
|
||||
const byCalls = [...rows].sort((a, b) => (b.calls || 0) - (a.calls || 0));
|
||||
const byRows = [...rows].sort((a, b) => (b.rows || 0) - (a.rows || 0));
|
||||
const byPriority = [...rows].sort((a, b) => scoreQuery(b) - scoreQuery(a));
|
||||
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" },
|
||||
@@ -88,6 +145,17 @@ export function QueryInsightsPage() {
|
||||
))}
|
||||
</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 ? (
|
||||
@@ -147,10 +215,10 @@ export function QueryInsightsPage() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{byPriority.map((r, i) => {
|
||||
{paged.map((r, i) => {
|
||||
const state = classifyQuery(r);
|
||||
return (
|
||||
<tr key={`${r.queryid}-${i}`} className={selectedQuery?.queryid === r.queryid ? "active-row" : ""}>
|
||||
<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>
|
||||
@@ -166,6 +234,20 @@ export function QueryInsightsPage() {
|
||||
})}
|
||||
</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">
|
||||
@@ -181,6 +263,12 @@ export function QueryInsightsPage() {
|
||||
<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>
|
||||
|
||||
@@ -255,7 +255,8 @@ button {
|
||||
|
||||
.query-insights-page .query-toolbar {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(240px, 420px);
|
||||
grid-template-columns: minmax(220px, 320px) minmax(320px, 1fr);
|
||||
gap: 12px;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
@@ -346,6 +347,30 @@ button {
|
||||
font-family: "Space Grotesk", "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
margin-top: 10px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.pagination-info {
|
||||
font-size: 12px;
|
||||
color: #9eb8d6;
|
||||
}
|
||||
|
||||
.pagination-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.pagination-actions button {
|
||||
min-height: 30px;
|
||||
padding: 5px 10px;
|
||||
}
|
||||
|
||||
.query-detail .sql-block {
|
||||
margin-top: 10px;
|
||||
background: #0a1c3a;
|
||||
@@ -365,6 +390,29 @@ button {
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.query-tips {
|
||||
margin-top: 10px;
|
||||
border: 1px solid #2d5f92;
|
||||
border-radius: 10px;
|
||||
padding: 10px;
|
||||
background: #0d2141;
|
||||
}
|
||||
|
||||
.query-tips h4 {
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.query-tips ul {
|
||||
margin: 0;
|
||||
padding-left: 16px;
|
||||
}
|
||||
|
||||
.query-tips li {
|
||||
margin-bottom: 6px;
|
||||
color: #d2e3fb;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.query-hint {
|
||||
margin-top: 10px;
|
||||
color: #9eb8d6;
|
||||
|
||||
Reference in New Issue
Block a user