diff --git a/frontend/src/pages/QueryInsightsPage.jsx b/frontend/src/pages/QueryInsightsPage.jsx index 7bc8760..3ebff95 100644 --- a/frontend/src/pages/QueryInsightsPage.jsx +++ b/frontend/src/pages/QueryInsightsPage.jsx @@ -2,12 +2,34 @@ import React, { useEffect, useState } from "react"; import { apiFetch } from "../api"; import { useAuth } from "../state"; +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(); +} + 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 [error, setError] = useState(""); + const [loading, setLoading] = useState(true); useEffect(() => { (async () => { @@ -17,6 +39,8 @@ export function QueryInsightsPage() { if (t.length > 0) setTargetId(String(t[0].id)); } catch (e) { setError(String(e.message || e)); + } finally { + setLoading(false); } })(); }, []); @@ -27,53 +51,147 @@ export function QueryInsightsPage() { try { const data = await apiFetch(`/targets/${targetId}/top-queries`, {}, tokens, refresh); setRows(data); + setSelectedQuery(data[0] || null); } 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 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 ( -
+

Query Insights

Note: This section requires the pg_stat_statements extension on the monitored target.

{error &&
{error}
} -
- - -
-
- - - - - - - - - - - - - {rows.map((r, i) => ( - - - - - - - - +
+
+ +
-
TimeCallsTotal msMean msRowsQuery
{new Date(r.ts).toLocaleString()}{r.calls}{r.total_time.toFixed(2)}{r.mean_time.toFixed(2)}{r.rows}{r.query_text || "-"}
+ +
+ + {loading ? ( +
Loading query insights...
+ ) : ( + <> +
+ {categories.map((item) => { + const r = item.row; + const state = r ? classifyQuery(r) : null; + return ( +
r && setSelectedQuery(r)} + role="button" + tabIndex={0} + > +

{item.title}

+ {item.subtitle} + {r ? ( + <> +
{state.label}
+
+ Total + {Number(r.total_time || 0).toFixed(2)} ms +
+
+ Mean + {Number(r.mean_time || 0).toFixed(2)} ms +
+
+ Calls + {r.calls} +
+ + ) : ( +

No data

+ )} +
+ ); + })} +
+ +
+
+

Ranked Queries

+ + + + + + + + + + + + + {byPriority.map((r, i) => { + const state = classifyQuery(r); + return ( + + + + + + + + + ); + })} + +
PriorityCallsTotal msMean msRowsQuery Preview
{state.label}{r.calls}{Number(r.total_time || 0).toFixed(2)}{Number(r.mean_time || 0).toFixed(2)}{r.rows} + +
+
+ +
+

Selected Query

+ {selectedQuery ? ( + <> +
+
Calls{selectedQuery.calls}
+
Total Time{Number(selectedQuery.total_time || 0).toFixed(2)} ms
+
Mean Time{Number(selectedQuery.mean_time || 0).toFixed(2)} ms
+
Rows{selectedQuery.rows}
+
+
+ {selectedQuery.query_text || "-- no query text available --"} +
+

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

+ + ) : ( +

No query selected.

+ )} +
+
+ + )}
); } diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 6ecb3ca..717133c 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -253,6 +253,124 @@ button { color: #bfd0ea; } +.query-insights-page .query-toolbar { + display: grid; + grid-template-columns: minmax(240px, 420px); + align-items: end; +} + +.query-category { + cursor: pointer; + transition: transform 0.15s ease, border-color 0.15s ease; +} + +.query-category:hover { + transform: translateY(-1px); + border-color: #4da8ee; +} + +.query-category h4 { + margin: 0 0 2px 0; +} + +.query-category small { + color: #9bb5d4; + display: block; + margin-bottom: 8px; +} + +.query-kpi { + margin: 4px 0; +} + +.query-kpi span { + display: block; + color: #9bb5d4; + font-size: 12px; +} + +.query-kpi strong { + font-size: 14px; +} + +.query-state { + display: inline-block; + padding: 3px 8px; + border-radius: 999px; + font-size: 11px; + font-weight: 700; + margin-bottom: 6px; + border: 1px solid transparent; +} + +.query-state.danger { + color: #fecaca; + background: #3b1d23; + border-color: #b64a4a; +} + +.query-state.warn { + color: #fde68a; + background: #3c2d14; + border-color: #9b7b2f; +} + +.query-state.info { + color: #bfdbfe; + background: #172a4a; + border-color: #3d67a7; +} + +.query-state.ok { + color: #bbf7d0; + background: #133126; + border-color: #2f8f63; +} + +.query-list table tbody tr.active-row { + background: #15345f70; +} + +.query-list .table-link { + display: inline-block; + border: 0; + background: transparent; + color: #d7e7ff; + padding: 0; + text-align: left; + cursor: pointer; + width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-family: "Space Grotesk", "Segoe UI", sans-serif; +} + +.query-detail .sql-block { + margin-top: 10px; + background: #0a1c3a; + border: 1px solid #2b578b; + border-radius: 10px; + padding: 10px; + max-height: 360px; + overflow: auto; +} + +.query-detail .sql-block code { + white-space: pre-wrap; + word-break: break-word; + color: #d4e7ff; + font-family: "Consolas", "SFMono-Regular", Menlo, monospace; + font-size: 12px; + line-height: 1.5; +} + +.query-hint { + margin-top: 10px; + color: #9eb8d6; + font-size: 13px; +} + .targets-page h2 { margin-top: 4px; margin-bottom: 16px;