From c63e08748c98922fd56c17b52d9f6ed3b31bd220 Mon Sep 17 00:00:00 2001 From: nessi Date: Thu, 12 Feb 2026 12:09:47 +0100 Subject: [PATCH] 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. --- frontend/src/pages/QueryInsightsPage.jsx | 102 +++++++++++++++++++++-- frontend/src/styles.css | 50 ++++++++++- 2 files changed, 144 insertions(+), 8 deletions(-) diff --git a/frontend/src/pages/QueryInsightsPage.jsx b/frontend/src/pages/QueryInsightsPage.jsx index 3ebff95..4cc1333 100644 --- a/frontend/src/pages/QueryInsightsPage.jsx +++ b/frontend/src/pages/QueryInsightsPage.jsx @@ -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() { ))} +
+ + { + setSearch(e.target.value); + setPage(1); + }} + placeholder="e.g. select * from users" + /> +
{loading ? ( @@ -147,10 +215,10 @@ export function QueryInsightsPage() { - {byPriority.map((r, i) => { + {paged.map((r, i) => { const state = classifyQuery(r); return ( - + {state.label} {r.calls} {Number(r.total_time || 0).toFixed(2)} @@ -166,6 +234,20 @@ export function QueryInsightsPage() { })} +
+ + Showing {paged.length} of {filtered.length} queries + +
+ + Page {safePage} / {totalPages} + +
+
@@ -181,6 +263,12 @@ export function QueryInsightsPage() {
{selectedQuery.query_text || "-- no query text available --"}
+
+

Optimization Suggestions

+
    + {selectedTips.map((tip, idx) =>
  • {tip}
  • )} +
+

Tip: focus first on queries with high Total Time (overall impact) and high Mean Time (latency hotspots).

diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 717133c..a81a673 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -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;