dbopt-engine 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +19 -0
- package/README.md +143 -0
- package/bin/cli.js +51 -0
- package/package.json +46 -0
- package/src/adapters/index.js +20 -0
- package/src/adapters/mssql.js +236 -0
- package/src/adapters/mysql.js +177 -0
- package/src/adapters/postgres.js +137 -0
- package/src/config.js +140 -0
- package/src/executor.js +127 -0
- package/src/index.js +55 -0
- package/src/planParser.js +35 -0
- package/src/routes.js +160 -0
- package/src/scheduler.js +132 -0
- package/src/static/dashboard.html +551 -0
- package/src/static/topology.html +926 -0
- package/src/store.js +155 -0
- package/src/targets.js +17 -0
- package/src/topology.js +217 -0
|
@@ -0,0 +1,551 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>DBOpt — Dashboard</title>
|
|
7
|
+
<style>
|
|
8
|
+
:root {
|
|
9
|
+
--page: #f9f9f7;
|
|
10
|
+
--surface-1: #fcfcfb;
|
|
11
|
+
--text-primary: #0b0b0b;
|
|
12
|
+
--text-secondary:#52514e;
|
|
13
|
+
--text-muted: #898781;
|
|
14
|
+
--grid: #e1e0d9;
|
|
15
|
+
--baseline: #c3c2b7;
|
|
16
|
+
--border: rgba(11,11,11,0.10);
|
|
17
|
+
--series-1: #2a78d6; /* blue: slow queries */
|
|
18
|
+
--series-2: #1baf7a; /* aqua: table sizes */
|
|
19
|
+
--status-good: #0ca30c;
|
|
20
|
+
--status-critical: #d03b3b;
|
|
21
|
+
--status-warning: #fab219;
|
|
22
|
+
}
|
|
23
|
+
@media (prefers-color-scheme: dark) {
|
|
24
|
+
:root {
|
|
25
|
+
--page: #0d0d0d;
|
|
26
|
+
--surface-1: #1a1a19;
|
|
27
|
+
--text-primary: #ffffff;
|
|
28
|
+
--text-secondary:#c3c2b7;
|
|
29
|
+
--text-muted: #898781;
|
|
30
|
+
--grid: #2c2c2a;
|
|
31
|
+
--baseline: #383835;
|
|
32
|
+
--border: rgba(255,255,255,0.10);
|
|
33
|
+
--series-1: #3987e5;
|
|
34
|
+
--series-2: #199e70;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
* { box-sizing: border-box; margin: 0; }
|
|
38
|
+
body {
|
|
39
|
+
background: var(--page);
|
|
40
|
+
color: var(--text-primary);
|
|
41
|
+
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
|
|
42
|
+
font-size: 14px;
|
|
43
|
+
padding: 24px;
|
|
44
|
+
max-width: 1200px;
|
|
45
|
+
margin: 0 auto;
|
|
46
|
+
}
|
|
47
|
+
header { display: flex; align-items: baseline; gap: 12px; margin-bottom: 12px; flex-wrap: wrap; }
|
|
48
|
+
header h1 { font-size: 20px; font-weight: 600; }
|
|
49
|
+
.chip-row { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 20px; }
|
|
50
|
+
.chip {
|
|
51
|
+
border: 1px solid var(--border); border-radius: 999px;
|
|
52
|
+
padding: 3px 12px; font-size: 12px; color: var(--text-secondary);
|
|
53
|
+
background: var(--surface-1); display: inline-flex; gap: 6px; align-items: center;
|
|
54
|
+
}
|
|
55
|
+
.chip b { color: var(--text-primary); font-weight: 600; }
|
|
56
|
+
.chip .dot { width: 8px; height: 8px; border-radius: 50%; }
|
|
57
|
+
#setup-banner {
|
|
58
|
+
display: none; border: 1px solid var(--border); border-left: 4px solid var(--status-warning);
|
|
59
|
+
background: var(--surface-1); border-radius: 10px; padding: 12px 16px;
|
|
60
|
+
margin-bottom: 20px; font-size: 13px;
|
|
61
|
+
}
|
|
62
|
+
#setup-banner .t { font-weight: 600; margin-bottom: 4px; }
|
|
63
|
+
#setup-banner li { margin-left: 18px; color: var(--text-secondary); }
|
|
64
|
+
#last-updated { color: var(--text-muted); font-size: 12px; margin-left: auto; }
|
|
65
|
+
#auto-status { font-size: 12px; color: var(--text-secondary); }
|
|
66
|
+
|
|
67
|
+
.kpi-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 12px; margin-bottom: 20px; }
|
|
68
|
+
.tile {
|
|
69
|
+
background: var(--surface-1);
|
|
70
|
+
border: 1px solid var(--border);
|
|
71
|
+
border-radius: 10px;
|
|
72
|
+
padding: 14px 16px;
|
|
73
|
+
}
|
|
74
|
+
.tile .label { color: var(--text-secondary); font-size: 12px; margin-bottom: 6px; }
|
|
75
|
+
.tile .value { font-size: 30px; font-weight: 600; line-height: 1.1; }
|
|
76
|
+
.tile .sub { color: var(--text-muted); font-size: 12px; margin-top: 4px; }
|
|
77
|
+
|
|
78
|
+
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-bottom: 20px; }
|
|
79
|
+
@media (max-width: 860px) { .grid-2 { grid-template-columns: 1fr; } }
|
|
80
|
+
|
|
81
|
+
.card {
|
|
82
|
+
background: var(--surface-1);
|
|
83
|
+
border: 1px solid var(--border);
|
|
84
|
+
border-radius: 10px;
|
|
85
|
+
padding: 16px;
|
|
86
|
+
margin-bottom: 20px;
|
|
87
|
+
}
|
|
88
|
+
.card h2 { font-size: 14px; font-weight: 600; margin-bottom: 2px; }
|
|
89
|
+
.card .card-sub { color: var(--text-muted); font-size: 12px; margin-bottom: 14px; }
|
|
90
|
+
.info {
|
|
91
|
+
cursor: help; color: var(--text-muted); font-size: 10px; font-weight: 600;
|
|
92
|
+
border: 1px solid var(--baseline); border-radius: 50%;
|
|
93
|
+
width: 15px; height: 15px; flex: none;
|
|
94
|
+
display: inline-flex; align-items: center; justify-content: center;
|
|
95
|
+
margin-left: 6px; vertical-align: 2px; user-select: none;
|
|
96
|
+
}
|
|
97
|
+
.info:hover, .info:focus-visible { color: var(--text-primary); border-color: var(--text-muted); outline: none; }
|
|
98
|
+
|
|
99
|
+
/* Horizontal bar rows */
|
|
100
|
+
.bar-row { display: grid; grid-template-columns: 180px 1fr; gap: 10px; align-items: center; padding: 5px 0; }
|
|
101
|
+
.bar-row .cat {
|
|
102
|
+
color: var(--text-secondary); font-size: 12px;
|
|
103
|
+
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; text-align: right;
|
|
104
|
+
}
|
|
105
|
+
.bar-track { position: relative; height: 22px; display: flex; align-items: center; }
|
|
106
|
+
.bar {
|
|
107
|
+
height: 16px; max-height: 16px;
|
|
108
|
+
border-radius: 0 4px 4px 0; /* rounded data-end, square at baseline */
|
|
109
|
+
min-width: 2px;
|
|
110
|
+
transition: opacity .15s;
|
|
111
|
+
}
|
|
112
|
+
.bar-track .val { margin-left: 8px; font-size: 12px; color: var(--text-secondary); white-space: nowrap; }
|
|
113
|
+
.bar-track:hover .bar, .bar-track:focus-visible .bar { opacity: .8; }
|
|
114
|
+
.bar-track { outline: none; }
|
|
115
|
+
.axis-note { border-top: 1px solid var(--grid); margin-top: 10px; padding-top: 6px; color: var(--text-muted); font-size: 11px; }
|
|
116
|
+
|
|
117
|
+
/* Tooltip */
|
|
118
|
+
#tooltip {
|
|
119
|
+
position: fixed; z-index: 10; pointer-events: none; display: none;
|
|
120
|
+
background: var(--surface-1); border: 1px solid var(--border); border-radius: 8px;
|
|
121
|
+
padding: 8px 10px; max-width: 420px; box-shadow: 0 4px 16px rgba(0,0,0,.15);
|
|
122
|
+
font-size: 12px;
|
|
123
|
+
}
|
|
124
|
+
#tooltip .t-val { font-weight: 600; font-size: 13px; }
|
|
125
|
+
#tooltip .t-label { color: var(--text-secondary); margin-top: 2px; word-break: break-word; }
|
|
126
|
+
|
|
127
|
+
table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
|
128
|
+
th { text-align: left; color: var(--text-muted); font-weight: 500; font-size: 12px; padding: 6px 10px; border-bottom: 1px solid var(--baseline); }
|
|
129
|
+
td { padding: 7px 10px; border-bottom: 1px solid var(--grid); vertical-align: top; }
|
|
130
|
+
td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; }
|
|
131
|
+
tr:last-child td { border-bottom: none; }
|
|
132
|
+
.table-scroll { overflow-x: auto; }
|
|
133
|
+
code { font-family: ui-monospace, monospace; font-size: 12px; color: var(--text-secondary); word-break: break-all; }
|
|
134
|
+
td.query-cell { min-width: 380px; }
|
|
135
|
+
td.query-cell code { display: block; white-space: pre-wrap; word-break: break-word; line-height: 1.5; }
|
|
136
|
+
td.num { white-space: nowrap; }
|
|
137
|
+
.status { display: inline-flex; align-items: center; gap: 5px; font-size: 12px; }
|
|
138
|
+
.status .dot { width: 8px; height: 8px; border-radius: 50%; flex: none; }
|
|
139
|
+
.empty { color: var(--text-muted); padding: 12px 0; font-size: 13px; }
|
|
140
|
+
.refreshing { opacity: .55; transition: opacity .2s; }
|
|
141
|
+
</style>
|
|
142
|
+
</head>
|
|
143
|
+
<body>
|
|
144
|
+
<header>
|
|
145
|
+
<h1>DBOpt — Dashboard</h1>
|
|
146
|
+
<select id="db-select" title="Switch database" style="display:none; font-size:12px; color:var(--text-secondary);
|
|
147
|
+
border:1px solid var(--border); border-radius:999px; padding:3px 10px; background:var(--surface-1);"></select>
|
|
148
|
+
<span id="auto-status">…</span>
|
|
149
|
+
<a id="topo-link" href="/topology" style="font-size:12px; color:var(--text-secondary); border:1px solid var(--border);
|
|
150
|
+
border-radius:999px; padding:3px 12px; text-decoration:none; background:var(--surface-1);">🕸 Topology</a>
|
|
151
|
+
<span id="last-updated"></span>
|
|
152
|
+
</header>
|
|
153
|
+
|
|
154
|
+
<div class="chip-row" id="env-chips"></div>
|
|
155
|
+
|
|
156
|
+
<div id="setup-banner"><div class="t">Server setup needed</div><ul id="setup-list"></ul></div>
|
|
157
|
+
|
|
158
|
+
<div class="kpi-row" id="kpis"></div>
|
|
159
|
+
|
|
160
|
+
<div class="grid-2">
|
|
161
|
+
<div class="card">
|
|
162
|
+
<h2>Slowest queries <span class="info" tabindex="0" data-tip="Queries ranked by average time per single execution. These are the ones a user feels as 'slow'. Hover a bar to read the full query text and call count — if one of these does a full table scan, the engine will try to fix it with an index.">i</span></h2>
|
|
163
|
+
<div class="card-sub">Mean execution time per call</div>
|
|
164
|
+
<div id="slow-queries-chart"></div>
|
|
165
|
+
</div>
|
|
166
|
+
<div class="card">
|
|
167
|
+
<h2>Biggest tables <span class="info" tabindex="0" data-tip="Tables by total size on disk (data + indexes). Big tables are where a missing index hurts most and where the engine focuses. Hover a bar for row counts.">i</span></h2>
|
|
168
|
+
<div class="card-sub">Total relation size (table + indexes)</div>
|
|
169
|
+
<div id="tables-chart"></div>
|
|
170
|
+
</div>
|
|
171
|
+
</div>
|
|
172
|
+
|
|
173
|
+
<div class="card">
|
|
174
|
+
<h2>Heaviest queries by total load <span class="info" tabindex="0" data-tip="Different from 'slowest': a 5 ms query called a million times costs more than a 2-second query called once. This ranks by accumulated total time — the queries that consume most of your database's capacity overall.">i</span></h2>
|
|
175
|
+
<div class="card-sub">calls × time — where the database actually spends its life</div>
|
|
176
|
+
<div class="table-scroll"><table id="load-table"></table></div>
|
|
177
|
+
</div>
|
|
178
|
+
|
|
179
|
+
<div class="card">
|
|
180
|
+
<h2>Unused indexes right now <span class="info" tabindex="0" data-tip="Indexes that have never been read since the database server last started. They cost storage and slow down every write. Safe candidates only — primary keys, unique keys, foreign-key and FULLTEXT indexes are never listed. With auto-drop on, these get removed in the next cycle; otherwise apply them one by one from the recommendations API.">i</span></h2>
|
|
181
|
+
<div class="card-sub">Zero scans since server start · PK, unique, FK-required and FULLTEXT indexes excluded</div>
|
|
182
|
+
<div class="table-scroll"><table id="unused-table"></table></div>
|
|
183
|
+
</div>
|
|
184
|
+
|
|
185
|
+
<div class="card">
|
|
186
|
+
<h2>Actions taken by the engine <span class="info" tabindex="0" data-tip="The audit log: every index the engine created or dropped by itself, with the exact SQL it ran and the measured impact. Every action is reversible — POST /recommendations/{id}/rollback undoes it using the stored rollback SQL.">i</span></h2>
|
|
187
|
+
<div class="card-sub">Indexes created and dropped autonomously, newest first</div>
|
|
188
|
+
<div class="table-scroll"><table id="actions-table"></table></div>
|
|
189
|
+
</div>
|
|
190
|
+
|
|
191
|
+
<div class="card">
|
|
192
|
+
<h2>Recommendations <span class="info" tabindex="0" data-tip="Everything the analyzer has ever found, in every state: pending (waiting), applied (done), rejected (dismissed), failed, skipped (cooldown), rolled back. Pending items show the improvement the index was verified to deliver before anything is created.">i</span></h2>
|
|
193
|
+
<div class="card-sub">Everything the analyzer has found (all statuses)</div>
|
|
194
|
+
<div class="table-scroll"><table id="recs-table"></table></div>
|
|
195
|
+
</div>
|
|
196
|
+
|
|
197
|
+
<div id="tooltip" role="tooltip"><div class="t-val"></div><div class="t-label"></div></div>
|
|
198
|
+
|
|
199
|
+
<script>
|
|
200
|
+
"use strict";
|
|
201
|
+
const $ = (sel) => document.querySelector(sel);
|
|
202
|
+
const cssVar = (name) => getComputedStyle(document.documentElement).getPropertyValue(name).trim();
|
|
203
|
+
|
|
204
|
+
// ---------- tooltip (per-mark hover; every value is also directly labeled) ----------
|
|
205
|
+
const tip = $("#tooltip");
|
|
206
|
+
function bindTooltip(el, value, label) {
|
|
207
|
+
const show = (e) => {
|
|
208
|
+
tip.querySelector(".t-val").textContent = value;
|
|
209
|
+
tip.querySelector(".t-label").textContent = label;
|
|
210
|
+
tip.style.display = "block";
|
|
211
|
+
const x = Math.min(e.clientX + 14, window.innerWidth - tip.offsetWidth - 8);
|
|
212
|
+
tip.style.left = x + "px";
|
|
213
|
+
tip.style.top = (e.clientY + 14) + "px";
|
|
214
|
+
};
|
|
215
|
+
el.addEventListener("pointermove", show);
|
|
216
|
+
el.addEventListener("pointerleave", () => tip.style.display = "none");
|
|
217
|
+
el.addEventListener("focus", () => {
|
|
218
|
+
tip.querySelector(".t-val").textContent = value;
|
|
219
|
+
tip.querySelector(".t-label").textContent = label;
|
|
220
|
+
tip.style.display = "block";
|
|
221
|
+
const r = el.getBoundingClientRect();
|
|
222
|
+
tip.style.left = r.left + "px";
|
|
223
|
+
tip.style.top = (r.bottom + 6) + "px";
|
|
224
|
+
});
|
|
225
|
+
el.addEventListener("blur", () => tip.style.display = "none");
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// ---------- horizontal bar list (single series -> one hue, no legend) ----------
|
|
229
|
+
function renderBars(container, rows, colorVar) {
|
|
230
|
+
container.replaceChildren();
|
|
231
|
+
if (!rows.length) {
|
|
232
|
+
const d = document.createElement("div");
|
|
233
|
+
d.className = "empty";
|
|
234
|
+
d.textContent = "No data yet.";
|
|
235
|
+
container.appendChild(d);
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
const max = Math.max(...rows.map(r => r.value)) || 1;
|
|
239
|
+
for (const r of rows) {
|
|
240
|
+
const row = document.createElement("div");
|
|
241
|
+
row.className = "bar-row";
|
|
242
|
+
const cat = document.createElement("div");
|
|
243
|
+
cat.className = "cat";
|
|
244
|
+
cat.textContent = r.category;
|
|
245
|
+
const track = document.createElement("div");
|
|
246
|
+
track.className = "bar-track";
|
|
247
|
+
track.tabIndex = 0;
|
|
248
|
+
const bar = document.createElement("div");
|
|
249
|
+
bar.className = "bar";
|
|
250
|
+
bar.style.width = Math.max(1.5, (r.value / max) * 100 * 0.72) + "%";
|
|
251
|
+
bar.style.background = cssVar(colorVar);
|
|
252
|
+
const val = document.createElement("span");
|
|
253
|
+
val.className = "val";
|
|
254
|
+
val.textContent = r.valueLabel; // direct label at the bar tip
|
|
255
|
+
track.append(bar, val);
|
|
256
|
+
bindTooltip(track, r.valueLabel, r.tooltip || r.category);
|
|
257
|
+
row.append(cat, track);
|
|
258
|
+
container.appendChild(row);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// ---------- formatting ----------
|
|
263
|
+
const fmtMs = (ms) => ms >= 1000 ? (ms / 1000).toFixed(2) + " s" : ms.toFixed(1) + " ms";
|
|
264
|
+
function fmtDur(ms) { // for accumulated totals, which can reach minutes
|
|
265
|
+
if (ms >= 60000) return (ms / 60000).toFixed(1) + " min";
|
|
266
|
+
return fmtMs(ms);
|
|
267
|
+
}
|
|
268
|
+
function fmtBytes(b) {
|
|
269
|
+
if (b >= 1 << 30) return (b / (1 << 30)).toFixed(1) + " GB";
|
|
270
|
+
if (b >= 1 << 20) return (b / (1 << 20)).toFixed(1) + " MB";
|
|
271
|
+
if (b >= 1 << 10) return (b / (1 << 10)).toFixed(0) + " kB";
|
|
272
|
+
return b + " B";
|
|
273
|
+
}
|
|
274
|
+
const fmtDate = (iso) => iso ? new Date(iso).toLocaleString() : "—";
|
|
275
|
+
|
|
276
|
+
// ---------- status chip: icon + label, never color alone ----------
|
|
277
|
+
const STATUS = {
|
|
278
|
+
applied: { color: "--status-good", icon: "✓" },
|
|
279
|
+
failed: { color: "--status-critical", icon: "✗" },
|
|
280
|
+
rolled_back: { color: "--status-warning", icon: "↺" },
|
|
281
|
+
pending: { color: "--text-muted", icon: "•" },
|
|
282
|
+
skipped: { color: "--text-muted", icon: "–" },
|
|
283
|
+
rejected: { color: "--text-muted", icon: "–" },
|
|
284
|
+
acknowledged:{ color: "--text-muted", icon: "•" },
|
|
285
|
+
};
|
|
286
|
+
function statusChip(status) {
|
|
287
|
+
const s = STATUS[status] || STATUS.pending;
|
|
288
|
+
const span = document.createElement("span");
|
|
289
|
+
span.className = "status";
|
|
290
|
+
const dot = document.createElement("span");
|
|
291
|
+
dot.className = "dot";
|
|
292
|
+
dot.style.background = cssVar(s.color);
|
|
293
|
+
const txt = document.createElement("span");
|
|
294
|
+
txt.textContent = s.icon + " " + status;
|
|
295
|
+
span.append(dot, txt);
|
|
296
|
+
return span;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function buildTable(tableEl, headers, rows, buildCells) {
|
|
300
|
+
tableEl.replaceChildren();
|
|
301
|
+
const thead = document.createElement("thead");
|
|
302
|
+
const trh = document.createElement("tr");
|
|
303
|
+
for (const h of headers) {
|
|
304
|
+
const th = document.createElement("th");
|
|
305
|
+
if (h.startsWith(">")) { th.className = "num"; th.textContent = h.slice(1); }
|
|
306
|
+
else th.textContent = h;
|
|
307
|
+
trh.appendChild(th);
|
|
308
|
+
}
|
|
309
|
+
thead.appendChild(trh);
|
|
310
|
+
const tbody = document.createElement("tbody");
|
|
311
|
+
if (!rows.length) {
|
|
312
|
+
const tr = document.createElement("tr");
|
|
313
|
+
const td = document.createElement("td");
|
|
314
|
+
td.colSpan = headers.length;
|
|
315
|
+
td.className = "empty";
|
|
316
|
+
td.textContent = "Nothing here yet.";
|
|
317
|
+
tr.appendChild(td);
|
|
318
|
+
tbody.appendChild(tr);
|
|
319
|
+
}
|
|
320
|
+
for (const r of rows) {
|
|
321
|
+
const tr = document.createElement("tr");
|
|
322
|
+
for (const cell of buildCells(r)) tr.appendChild(cell);
|
|
323
|
+
tbody.appendChild(tr);
|
|
324
|
+
}
|
|
325
|
+
tableEl.append(thead, tbody);
|
|
326
|
+
}
|
|
327
|
+
function td(content, cls) {
|
|
328
|
+
const el = document.createElement("td");
|
|
329
|
+
if (cls) el.className = cls;
|
|
330
|
+
if (content instanceof Node) el.appendChild(content);
|
|
331
|
+
else el.textContent = content ?? "—";
|
|
332
|
+
return el;
|
|
333
|
+
}
|
|
334
|
+
function codeTd(text) {
|
|
335
|
+
const el = document.createElement("td");
|
|
336
|
+
const c = document.createElement("code");
|
|
337
|
+
c.textContent = text || "—";
|
|
338
|
+
el.appendChild(c);
|
|
339
|
+
return el;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// ---------- tiles ----------
|
|
343
|
+
function tile(label, value, sub, info) {
|
|
344
|
+
const t = document.createElement("div");
|
|
345
|
+
t.className = "tile";
|
|
346
|
+
const l = document.createElement("div"); l.className = "label"; l.textContent = label;
|
|
347
|
+
if (info) {
|
|
348
|
+
const i = document.createElement("span");
|
|
349
|
+
i.className = "info"; i.textContent = "i"; i.tabIndex = 0;
|
|
350
|
+
bindTooltip(i, label, info);
|
|
351
|
+
l.appendChild(i);
|
|
352
|
+
}
|
|
353
|
+
const v = document.createElement("div"); v.className = "value"; v.textContent = value;
|
|
354
|
+
t.append(l, v);
|
|
355
|
+
if (sub) { const s = document.createElement("div"); s.className = "sub"; s.textContent = sub; t.appendChild(s); }
|
|
356
|
+
return t;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// Hover help on the static cards: the ⓘ next to each title explains what the
|
|
360
|
+
// card shows and how to use it.
|
|
361
|
+
for (const el of document.querySelectorAll(".info[data-tip]")) {
|
|
362
|
+
bindTooltip(el, el.closest(".card").querySelector("h2").childNodes[0].textContent.trim(), el.dataset.tip);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// ---------- data loading ----------
|
|
366
|
+
// Every API call is scoped to the database picked in the header selector
|
|
367
|
+
// (?db=<name>); without it the server uses its first configured target.
|
|
368
|
+
const DB = new URLSearchParams(location.search).get("db");
|
|
369
|
+
const withDb = (url) => DB ? url + (url.includes("?") ? "&" : "?") + "db=" + encodeURIComponent(DB) : url;
|
|
370
|
+
async function getJSON(url) {
|
|
371
|
+
const res = await fetch(withDb(url));
|
|
372
|
+
if (!res.ok) throw new Error(url + " -> HTTP " + res.status);
|
|
373
|
+
return res.json();
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
async function initDbSelect() {
|
|
377
|
+
try {
|
|
378
|
+
const ts = await getJSON("/targets");
|
|
379
|
+
$("#topo-link").href = withDb("/topology");
|
|
380
|
+
if (ts.length < 2) return;
|
|
381
|
+
const sel = $("#db-select");
|
|
382
|
+
const current = DB || ts[0].name;
|
|
383
|
+
for (const t of ts) {
|
|
384
|
+
const o = document.createElement("option");
|
|
385
|
+
o.value = t.name;
|
|
386
|
+
o.textContent = t.name + " · " + ({mysql:"MySQL",mssql:"SQL Server",postgres:"PostgreSQL"}[t.engine] || t.engine);
|
|
387
|
+
if (t.name === current) o.selected = true;
|
|
388
|
+
sel.append(o);
|
|
389
|
+
}
|
|
390
|
+
sel.style.display = "";
|
|
391
|
+
sel.onchange = () => location.search = "?db=" + encodeURIComponent(sel.value);
|
|
392
|
+
} catch (e) { /* selector is optional */ }
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
async function refresh() {
|
|
396
|
+
document.body.classList.add("refreshing"); // hold previous render, dimmed — no skeleton flash
|
|
397
|
+
try {
|
|
398
|
+
const [overview, slow, slowAll, growth, actions, recs, health, config, unused] = await Promise.all([
|
|
399
|
+
getJSON("/analytics/overview"),
|
|
400
|
+
getJSON("/analytics/queries/top-slow?limit=8"),
|
|
401
|
+
getJSON("/queries/slow?limit=30"),
|
|
402
|
+
getJSON("/analytics/tables/top-by-size?limit=8"),
|
|
403
|
+
getJSON("/analytics/actions?limit=25"),
|
|
404
|
+
getJSON("/recommendations/"),
|
|
405
|
+
getJSON("/health"),
|
|
406
|
+
getJSON("/analytics/config"),
|
|
407
|
+
getJSON("/tables/unused-indexes"),
|
|
408
|
+
]);
|
|
409
|
+
|
|
410
|
+
// Environment chips: what is being watched, and how the engine will act
|
|
411
|
+
const chips = $("#env-chips");
|
|
412
|
+
const chip = (html) => { const c = document.createElement("span"); c.className = "chip"; c.append(...html); return c; };
|
|
413
|
+
const dot = (colorVar) => { const d = document.createElement("span"); d.className = "dot"; d.style.background = cssVar(colorVar); return d; };
|
|
414
|
+
const bold = (t) => { const b = document.createElement("b"); b.textContent = t; return b; };
|
|
415
|
+
const txt = (t) => document.createTextNode(t);
|
|
416
|
+
chips.replaceChildren(
|
|
417
|
+
chip([txt({mysql: "MySQL", mssql: "SQL Server", postgres: "PostgreSQL", oracle: "Oracle", mongodb: "MongoDB"}[config.engine] || config.engine), bold(config.database)]),
|
|
418
|
+
chip([txt("scans every"), bold(config.scan_interval_minutes + " min")]),
|
|
419
|
+
chip([dot(config.auto_create_indexes ? "--status-good" : "--text-muted"),
|
|
420
|
+
txt("auto-create " + (config.auto_create_indexes ? "on" : "off"))]),
|
|
421
|
+
chip([dot(config.auto_drop_unused_indexes ? "--status-good" : "--text-muted"),
|
|
422
|
+
txt("auto-drop " + (config.auto_drop_unused_indexes ? "on" : "off"))]),
|
|
423
|
+
chip([txt("acts above"), bold(config.min_improvement_pct + "% improvement")]),
|
|
424
|
+
);
|
|
425
|
+
|
|
426
|
+
// Setup banner: anything the bootstrap couldn't fix itself
|
|
427
|
+
const needed = (health.prerequisites && health.prerequisites.actions_needed) || [];
|
|
428
|
+
const banner = $("#setup-banner");
|
|
429
|
+
if (needed.length) {
|
|
430
|
+
const ul = $("#setup-list");
|
|
431
|
+
ul.replaceChildren();
|
|
432
|
+
for (const n of needed) { const li = document.createElement("li"); li.textContent = n; ul.appendChild(li); }
|
|
433
|
+
banner.style.display = "block";
|
|
434
|
+
} else {
|
|
435
|
+
banner.style.display = "none";
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// KPI row
|
|
439
|
+
const created = actions.filter(a => a.status === "applied" && a.type === "missing_index").length;
|
|
440
|
+
const dropped = actions.filter(a => a.status === "applied" && a.type === "unused_index").length;
|
|
441
|
+
const failed = actions.filter(a => a.status === "failed").length;
|
|
442
|
+
const kpis = $("#kpis");
|
|
443
|
+
kpis.replaceChildren(
|
|
444
|
+
tile("Pending recommendations",
|
|
445
|
+
overview.pending_missing_index_count + overview.pending_unused_index_count,
|
|
446
|
+
overview.pending_missing_index_count + " missing · " + overview.pending_unused_index_count + " unused",
|
|
447
|
+
"Verified findings waiting for action. With autonomous mode on they are executed within one scan cycle; with it off, apply them via POST /recommendations/{id}/apply."),
|
|
448
|
+
tile("Indexes created", created, "by the engine, automatically",
|
|
449
|
+
"Missing indexes the engine built on its own after verifying each one would cut the query's cost by at least the configured threshold."),
|
|
450
|
+
tile("Indexes dropped", dropped, "unused, reclaimed automatically",
|
|
451
|
+
"Unused indexes the engine removed. The original definition is stored as rollback SQL, so any drop can be undone."),
|
|
452
|
+
tile("Reclaimable storage", overview.total_wasted_index_storage_mb + " MB", "pending unused indexes",
|
|
453
|
+
"Disk space held by unused indexes that are detected but not yet dropped. Becomes zero once they are removed."),
|
|
454
|
+
tile("Failed actions", failed, failed ? "see actions table" : "all clear",
|
|
455
|
+
"Actions that errored when executed. The error message is stored on the action row; failed items can be retried with POST /recommendations/{id}/apply."),
|
|
456
|
+
);
|
|
457
|
+
|
|
458
|
+
$("#auto-status").textContent =
|
|
459
|
+
health.db_connection === "ok" ? "target DB connected · autonomous mode" : "target DB unreachable";
|
|
460
|
+
|
|
461
|
+
// Charts (single series each -> sequential single hue, direct-labeled)
|
|
462
|
+
renderBars($("#slow-queries-chart"),
|
|
463
|
+
slow.map(q => ({
|
|
464
|
+
category: q.query.replace(/\s+/g, " ").slice(0, 60),
|
|
465
|
+
value: q.mean_exec_time,
|
|
466
|
+
valueLabel: fmtMs(q.mean_exec_time),
|
|
467
|
+
tooltip: q.query.replace(/\s+/g, " ").slice(0, 300) + " · " + q.calls + " calls",
|
|
468
|
+
})), "--series-1");
|
|
469
|
+
|
|
470
|
+
renderBars($("#tables-chart"),
|
|
471
|
+
growth.map(t => ({
|
|
472
|
+
category: t.table_name,
|
|
473
|
+
value: t.total_size_bytes ?? 0,
|
|
474
|
+
valueLabel: fmtBytes(t.total_size_bytes ?? 0),
|
|
475
|
+
tooltip: t.table_name + " · " + Number(t.live_rows).toLocaleString() + " live rows · " +
|
|
476
|
+
t.seq_scan + " seq scans · " + t.idx_scan + " index scans",
|
|
477
|
+
})), "--series-2");
|
|
478
|
+
|
|
479
|
+
// Heaviest queries by total accumulated time
|
|
480
|
+
const byLoad = [...slowAll]
|
|
481
|
+
.sort((a, b) => (b.total_exec_time || 0) - (a.total_exec_time || 0))
|
|
482
|
+
.slice(0, 8);
|
|
483
|
+
buildTable($("#load-table"),
|
|
484
|
+
["Query", ">Calls", ">Mean", ">Total time"],
|
|
485
|
+
byLoad,
|
|
486
|
+
(q) => [
|
|
487
|
+
(() => { const c = codeTd(q.query.replace(/\s+/g, " ").trim()); c.classList.add("query-cell"); return c; })(),
|
|
488
|
+
td(Number(q.calls).toLocaleString(), "num"),
|
|
489
|
+
td(fmtMs(q.mean_exec_time), "num"),
|
|
490
|
+
td(fmtDur(q.total_exec_time), "num"),
|
|
491
|
+
]);
|
|
492
|
+
|
|
493
|
+
// Unused indexes live view
|
|
494
|
+
buildTable($("#unused-table"),
|
|
495
|
+
["Table", "Index", ">Size", ">Scans"],
|
|
496
|
+
unused.slice(0, 12),
|
|
497
|
+
(u) => [
|
|
498
|
+
td(u.table),
|
|
499
|
+
codeTd(u.index),
|
|
500
|
+
td(u.size_mb + " MB", "num"),
|
|
501
|
+
td(String(u.scans), "num"),
|
|
502
|
+
]);
|
|
503
|
+
|
|
504
|
+
// Actions table
|
|
505
|
+
buildTable($("#actions-table"),
|
|
506
|
+
["When", "Action", "Table", "SQL executed", ">Impact", "Status"],
|
|
507
|
+
actions,
|
|
508
|
+
(a) => [
|
|
509
|
+
td(fmtDate(a.applied_at || a.created_at)),
|
|
510
|
+
td(a.type === "missing_index" ? "Create index" : "Drop index"),
|
|
511
|
+
td(a.table),
|
|
512
|
+
codeTd(a.executed_sql || a.error),
|
|
513
|
+
td(a.improvement_pct != null ? "−" + a.improvement_pct + "% cost"
|
|
514
|
+
: (a.type === "unused_index" && a.detail ? "+" + (a.detail.size_mb || 0) + " MB freed" : "—"), "num"),
|
|
515
|
+
td(statusChip(a.status)),
|
|
516
|
+
]);
|
|
517
|
+
|
|
518
|
+
// Recommendations table
|
|
519
|
+
buildTable($("#recs-table"),
|
|
520
|
+
["Found", "Type", "Table", "Detail", ">Improvement", "Status"],
|
|
521
|
+
recs,
|
|
522
|
+
(r) => [
|
|
523
|
+
td(fmtDate(r.created_at)),
|
|
524
|
+
td(r.type === "missing_index" ? "Missing index" : "Unused index"),
|
|
525
|
+
td(r.table),
|
|
526
|
+
codeTd(r.type === "missing_index"
|
|
527
|
+
? "(" + (r.detail.columns || []).join(", ") + ")"
|
|
528
|
+
+ (r.detail.predicate
|
|
529
|
+
? " WHERE " + r.detail.predicate.column + " " + r.detail.predicate.op
|
|
530
|
+
+ (r.detail.predicate.value != null ? " " + r.detail.predicate.value : "")
|
|
531
|
+
: "")
|
|
532
|
+
+ " " + (r.detail.reason || "")
|
|
533
|
+
: r.detail.index_name + " · " + r.detail.size_mb + " MB"),
|
|
534
|
+
td(r.improvement_pct != null ? r.improvement_pct + " %" : "—", "num"),
|
|
535
|
+
td(statusChip(r.status)),
|
|
536
|
+
]);
|
|
537
|
+
|
|
538
|
+
$("#last-updated").textContent = "updated " + new Date().toLocaleTimeString();
|
|
539
|
+
} catch (e) {
|
|
540
|
+
$("#auto-status").textContent = "failed to load: " + e.message;
|
|
541
|
+
} finally {
|
|
542
|
+
document.body.classList.remove("refreshing");
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
initDbSelect();
|
|
547
|
+
refresh();
|
|
548
|
+
setInterval(refresh, 30000);
|
|
549
|
+
</script>
|
|
550
|
+
</body>
|
|
551
|
+
</html>
|