Add enhanced query insights UI with categorization
This update introduces a revamped Query Insights page with clear categorization and sorting based on optimization priority, execution frequency, and performance metrics. New features include query classification, compact SQL previews, and a detailed view for selected queries, improving user experience and actionable insights. Styling enhancements provide a more intuitive and visually appealing interface.
This commit is contained in:
@@ -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 (
|
||||
<div>
|
||||
<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>
|
||||
{error && <div className="card error">{error}</div>}
|
||||
<div className="card">
|
||||
<label>Target </label>
|
||||
<select value={targetId} onChange={(e) => setTargetId(e.target.value)}>
|
||||
{targets.map((t) => (
|
||||
<option key={t.id} value={t.id}>
|
||||
{t.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="card">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Time</th>
|
||||
<th>Calls</th>
|
||||
<th>Total ms</th>
|
||||
<th>Mean ms</th>
|
||||
<th>Rows</th>
|
||||
<th>Query</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r, i) => (
|
||||
<tr key={i}>
|
||||
<td>{new Date(r.ts).toLocaleString()}</td>
|
||||
<td>{r.calls}</td>
|
||||
<td>{r.total_time.toFixed(2)}</td>
|
||||
<td>{r.mean_time.toFixed(2)}</td>
|
||||
<td>{r.rows}</td>
|
||||
<td className="query">{r.query_text || "-"}</td>
|
||||
</tr>
|
||||
<div className="card query-toolbar">
|
||||
<div className="field">
|
||||
<label>Target</label>
|
||||
<select value={targetId} onChange={(e) => setTargetId(e.target.value)}>
|
||||
{targets.map((t) => (
|
||||
<option key={t.id} value={t.id}>
|
||||
{t.name}
|
||||
</option>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</select>
|
||||
</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>
|
||||
{byPriority.map((r, i) => {
|
||||
const state = classifyQuery(r);
|
||||
return (
|
||||
<tr key={`${r.queryid}-${i}`} 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>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user