mysql_genius 0.1.0 → 0.3.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.
- checksums.yaml +4 -4
- data/.github/FUNDING.yml +5 -0
- data/.github/workflows/ci.yml +30 -7
- data/.gitignore +3 -0
- data/.rubocop.yml +24 -0
- data/CHANGELOG.md +32 -0
- data/Gemfile +7 -2
- data/README.md +50 -38
- data/Rakefile +3 -1
- data/app/controllers/concerns/mysql_genius/ai_features.rb +90 -52
- data/app/controllers/concerns/mysql_genius/database_analysis.rb +73 -45
- data/app/controllers/concerns/mysql_genius/query_execution.rb +18 -16
- data/app/controllers/mysql_genius/base_controller.rb +3 -1
- data/app/controllers/mysql_genius/queries_controller.rb +19 -12
- data/app/services/mysql_genius/ai_client.rb +10 -3
- data/app/services/mysql_genius/ai_optimization_service.rb +8 -4
- data/app/services/mysql_genius/ai_suggestion_service.rb +6 -3
- data/app/views/layouts/mysql_genius/application.html.erb +141 -5
- data/app/views/mysql_genius/queries/_tab_dashboard.html.erb +95 -0
- data/app/views/mysql_genius/queries/_tab_duplicate_indexes.html.erb +11 -0
- data/app/views/mysql_genius/queries/_tab_query_explorer.html.erb +110 -0
- data/app/views/mysql_genius/queries/_tab_table_sizes.html.erb +6 -4
- data/app/views/mysql_genius/queries/_tab_unused_indexes.html.erb +11 -0
- data/app/views/mysql_genius/queries/index.html.erb +377 -52
- data/bin/console +1 -0
- data/config/routes.rb +2 -0
- data/docs/screenshots/dashboard.png +0 -0
- data/docs/screenshots/query_explore.png +0 -0
- data/docs/superpowers/plans/2026-04-08-dashboard-first-redesign.md +741 -0
- data/docs/superpowers/specs/2026-04-08-dashboard-first-redesign.md +87 -0
- data/lib/generators/mysql_genius/install/install_generator.rb +19 -0
- data/lib/generators/mysql_genius/install/templates/initializer.rb +56 -0
- data/lib/mysql_genius/configuration.rb +8 -6
- data/lib/mysql_genius/engine.rb +2 -0
- data/lib/mysql_genius/slow_query_monitor.rb +30 -25
- data/lib/mysql_genius/sql_validator.rb +6 -4
- data/lib/mysql_genius/version.rb +3 -1
- data/lib/mysql_genius.rb +2 -0
- data/mysql_genius.gemspec +9 -8
- metadata +31 -15
- data/app/views/mysql_genius/queries/_tab_sql_query.html.erb +0 -40
- data/app/views/mysql_genius/queries/_tab_visual_builder.html.erb +0 -61
- data/docs/screenshots/sql_query.png +0 -0
- data/docs/screenshots/visual_builder.png +0 -0
|
@@ -1,27 +1,32 @@
|
|
|
1
|
-
<
|
|
1
|
+
<div style="display:flex;align-items:center;justify-content:space-between;">
|
|
2
|
+
<h4>🐘 MySQLGenius</h4>
|
|
3
|
+
<button class="mg-theme-toggle" id="mg-theme-btn" title="Toggle dark/light theme" onclick="(function(){var d=document.documentElement,t=d.getAttribute('data-theme')==='dark'?'light':'dark';d.setAttribute('data-theme',t);localStorage.setItem('mg-theme',t);document.getElementById('mg-theme-btn').textContent=t==='dark'?'\u2600\uFE0F':'\uD83C\uDF19';})()">
|
|
4
|
+
<script>document.write(document.documentElement.getAttribute('data-theme')==='dark'?'\u2600\uFE0F':'\uD83C\uDF19')</script>
|
|
5
|
+
</button>
|
|
6
|
+
</div>
|
|
2
7
|
|
|
3
8
|
<div class="mg-tabs">
|
|
4
|
-
<button class="mg-tab active" data-tab="
|
|
5
|
-
<button class="mg-tab" data-tab="sql">SQL Query</button>
|
|
9
|
+
<button class="mg-tab active" data-tab="dashboard">Dashboard</button>
|
|
6
10
|
<button class="mg-tab" data-tab="slow">Slow Queries</button>
|
|
7
|
-
<button class="mg-tab" data-tab="indexes">Duplicate Indexes</button>
|
|
8
|
-
<button class="mg-tab" data-tab="sizes">Table Sizes</button>
|
|
9
11
|
<button class="mg-tab" data-tab="qstats">Query Stats</button>
|
|
10
|
-
<button class="mg-tab" data-tab="unused">Unused Indexes</button>
|
|
11
12
|
<button class="mg-tab" data-tab="server">Server</button>
|
|
13
|
+
<button class="mg-tab" data-tab="tables">Tables</button>
|
|
14
|
+
<button class="mg-tab" data-tab="unused">Unused Indexes</button>
|
|
15
|
+
<button class="mg-tab" data-tab="indexes">Duplicate Indexes</button>
|
|
16
|
+
<button class="mg-tab" data-tab="explorer">Query Explorer</button>
|
|
12
17
|
<% if @ai_enabled %>
|
|
13
18
|
<button class="mg-tab" data-tab="aitools">AI Tools</button>
|
|
14
19
|
<% end %>
|
|
15
20
|
</div>
|
|
16
21
|
|
|
17
|
-
<%= render "mysql_genius/queries/
|
|
18
|
-
<%= render "mysql_genius/queries/tab_sql_query" %>
|
|
22
|
+
<%= render "mysql_genius/queries/tab_dashboard" %>
|
|
19
23
|
<%= render "mysql_genius/queries/tab_slow_queries" %>
|
|
20
|
-
<%= render "mysql_genius/queries/tab_duplicate_indexes" %>
|
|
21
|
-
<%= render "mysql_genius/queries/tab_table_sizes" %>
|
|
22
24
|
<%= render "mysql_genius/queries/tab_query_stats" %>
|
|
23
|
-
<%= render "mysql_genius/queries/tab_unused_indexes" %>
|
|
24
25
|
<%= render "mysql_genius/queries/tab_server" %>
|
|
26
|
+
<%= render "mysql_genius/queries/tab_table_sizes" %>
|
|
27
|
+
<%= render "mysql_genius/queries/tab_unused_indexes" %>
|
|
28
|
+
<%= render "mysql_genius/queries/tab_duplicate_indexes" %>
|
|
29
|
+
<%= render "mysql_genius/queries/tab_query_explorer" %>
|
|
25
30
|
<% if @ai_enabled %>
|
|
26
31
|
<%= render "mysql_genius/queries/tab_ai_tools" %>
|
|
27
32
|
<% end %>
|
|
@@ -53,6 +58,8 @@
|
|
|
53
58
|
migration_risk: '<%= mysql_genius.migration_risk_path %>'
|
|
54
59
|
};
|
|
55
60
|
|
|
61
|
+
var RAILS_MIGRATION_VERSION = '<%= Rails::VERSION::MAJOR %>.<%= Rails::VERSION::MINOR %>';
|
|
62
|
+
|
|
56
63
|
var csrfMeta = document.querySelector('meta[name="csrf-token"]');
|
|
57
64
|
var csrfToken = csrfMeta ? csrfMeta.getAttribute('content') : '';
|
|
58
65
|
var currentColumns = [];
|
|
@@ -69,6 +76,114 @@
|
|
|
69
76
|
function hide(e) { e.classList.add('mg-hidden'); }
|
|
70
77
|
function escHtml(s) { var d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
|
|
71
78
|
|
|
79
|
+
// --- SQL Syntax Highlighting (single-pass tokenizer) ---
|
|
80
|
+
|
|
81
|
+
var SQL_KW_SET = {};
|
|
82
|
+
'SELECT FROM WHERE JOIN LEFT RIGHT INNER OUTER CROSS FULL ON AND OR NOT IN EXISTS BETWEEN LIKE IS NULL AS DISTINCT ALL UNION INTERSECT EXCEPT INSERT INTO VALUES UPDATE SET DELETE CREATE ALTER DROP TABLE INDEX VIEW TRIGGER PROCEDURE FUNCTION DATABASE SCHEMA IF THEN ELSE END CASE WHEN HAVING LIMIT OFFSET ASC DESC USING FORCE USE IGNORE WITH RECURSIVE OVER EXPLAIN ANALYZE SHOW DESCRIBE'.split(' ').forEach(function(w) { SQL_KW_SET[w] = true; });
|
|
83
|
+
|
|
84
|
+
var SQL_FN_SET = {};
|
|
85
|
+
'COUNT SUM AVG MIN MAX COALESCE IFNULL NULLIF CAST CONVERT CONCAT CONCAT_WS GROUP_CONCAT SUBSTRING SUBSTR REPLACE TRIM LTRIM RTRIM UPPER LOWER UCASE LCASE LENGTH CHAR_LENGTH NOW CURDATE CURTIME DATE_FORMAT DATE_ADD DATE_SUB DATEDIFF TIMESTAMPDIFF UNIX_TIMESTAMP FROM_UNIXTIME IF ELT FIELD FIND_IN_SET FORMAT HEX UNHEX MD5 SHA1 SHA2 AES_ENCRYPT AES_DECRYPT ROUND CEIL CEILING FLOOR ABS MOD POWER SQRT LOG LOG2 LOG10 RAND GREATEST LEAST JSON_EXTRACT JSON_UNQUOTE JSON_SET JSON_OBJECT JSON_ARRAY JSON_CONTAINS JSON_LENGTH ROW_NUMBER RANK DENSE_RANK LAG LEAD FIRST_VALUE LAST_VALUE NTH_VALUE'.split(' ').forEach(function(w) { SQL_FN_SET[w] = true; });
|
|
86
|
+
|
|
87
|
+
// Single-pass tokenizer regex: matches tokens in order of priority
|
|
88
|
+
// 1) single-quoted strings 2) backtick identifiers 3) numbers 4) words 5) operators 6) punctuation 7) ? placeholder 8) * star 9) whitespace 10) anything else
|
|
89
|
+
var SQL_TOKEN_RE = /'[^']*'|`[^`]*`|\b\d+(?:\.\d+)?\b|[A-Za-z_]\w*|\?\|>=|<=|<>|!=|[><=]|[(),;*]|\s+|./g;
|
|
90
|
+
|
|
91
|
+
function highlightSql(sql) {
|
|
92
|
+
if (!sql) return '';
|
|
93
|
+
var result = [];
|
|
94
|
+
var match;
|
|
95
|
+
SQL_TOKEN_RE.lastIndex = 0;
|
|
96
|
+
while ((match = SQL_TOKEN_RE.exec(sql)) !== null) {
|
|
97
|
+
var tok = match[0];
|
|
98
|
+
var ch = tok.charAt(0);
|
|
99
|
+
|
|
100
|
+
if (ch === "'") {
|
|
101
|
+
// String literal
|
|
102
|
+
result.push('<span class="mg-sql-str">' + escHtml(tok) + '</span>');
|
|
103
|
+
} else if (ch === '`') {
|
|
104
|
+
// Backtick-quoted identifier
|
|
105
|
+
result.push('<span class="mg-sql-tbl">' + escHtml(tok) + '</span>');
|
|
106
|
+
} else if (/^\d/.test(tok)) {
|
|
107
|
+
// Number
|
|
108
|
+
result.push('<span class="mg-sql-num">' + escHtml(tok) + '</span>');
|
|
109
|
+
} else if (/^[A-Za-z_]/.test(tok)) {
|
|
110
|
+
// Word: check if keyword, function, or plain identifier
|
|
111
|
+
var upper = tok.toUpperCase();
|
|
112
|
+
// Check for compound keywords (GROUP BY, ORDER BY, PARTITION BY)
|
|
113
|
+
var rest = sql.substring(SQL_TOKEN_RE.lastIndex);
|
|
114
|
+
var compoundMatch = rest.match(/^(\s+)(BY)\b/i);
|
|
115
|
+
if ((upper === 'GROUP' || upper === 'ORDER' || upper === 'PARTITION') && compoundMatch) {
|
|
116
|
+
result.push('<span class="mg-sql-kw">' + escHtml(tok) + escHtml(compoundMatch[1]) + escHtml(compoundMatch[2].toUpperCase()) + '</span>');
|
|
117
|
+
SQL_TOKEN_RE.lastIndex += compoundMatch[0].length;
|
|
118
|
+
} else if (SQL_FN_SET[upper] && rest.match(/^\s*\(/)) {
|
|
119
|
+
result.push('<span class="mg-sql-fn">' + escHtml(tok.toUpperCase()) + '</span>');
|
|
120
|
+
} else if (SQL_KW_SET[upper]) {
|
|
121
|
+
result.push('<span class="mg-sql-kw">' + escHtml(tok.toUpperCase()) + '</span>');
|
|
122
|
+
} else {
|
|
123
|
+
result.push(escHtml(tok));
|
|
124
|
+
}
|
|
125
|
+
} else if (tok === '?') {
|
|
126
|
+
result.push('<span class="mg-sql-placeholder">?</span>');
|
|
127
|
+
} else if (tok === '*') {
|
|
128
|
+
result.push('<span class="mg-sql-star">*</span>');
|
|
129
|
+
} else if (/^(>=|<=|<>|!=|[><=])$/.test(tok)) {
|
|
130
|
+
result.push('<span class="mg-sql-op">' + escHtml(tok) + '</span>');
|
|
131
|
+
} else if (/^[(),;]$/.test(tok)) {
|
|
132
|
+
result.push('<span class="mg-sql-punc">' + escHtml(tok) + '</span>');
|
|
133
|
+
} else {
|
|
134
|
+
// Whitespace or unknown
|
|
135
|
+
result.push(escHtml(tok));
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return result.join('');
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function sqlBlock(sql, maxLen) {
|
|
142
|
+
var truncated = maxLen && sql.length > maxLen;
|
|
143
|
+
var displaySql = truncated ? sql.substring(0, maxLen) + '...' : sql;
|
|
144
|
+
return '<code class="mg-sql-block" title="' + escHtml(sql) + '">' + highlightSql(displaySql) + '</code>';
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// --- Duration Formatting with Color ---
|
|
148
|
+
|
|
149
|
+
function formatDurationStyled(ms) {
|
|
150
|
+
var text = formatDuration(ms);
|
|
151
|
+
var cls = 'mg-dur-fast';
|
|
152
|
+
if (ms >= 1000) cls = 'mg-dur-slow';
|
|
153
|
+
else if (ms >= 100) cls = 'mg-dur-moderate';
|
|
154
|
+
return '<span class="' + cls + '">' + text + '</span>';
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function migrationTimestamp() {
|
|
158
|
+
var now = new Date();
|
|
159
|
+
return now.getFullYear().toString() +
|
|
160
|
+
('0' + (now.getMonth() + 1)).slice(-2) +
|
|
161
|
+
('0' + now.getDate()).slice(-2) +
|
|
162
|
+
('0' + now.getHours()).slice(-2) +
|
|
163
|
+
('0' + now.getMinutes()).slice(-2) +
|
|
164
|
+
('0' + now.getSeconds()).slice(-2);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function copyToClipboard(text, btn) {
|
|
168
|
+
var done = function() {
|
|
169
|
+
btn.innerHTML = '✓ Copied';
|
|
170
|
+
setTimeout(function() { btn.innerHTML = '📋 Copy'; }, 2000);
|
|
171
|
+
};
|
|
172
|
+
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
173
|
+
navigator.clipboard.writeText(text).then(done);
|
|
174
|
+
} else {
|
|
175
|
+
var ta = document.createElement('textarea');
|
|
176
|
+
ta.value = text;
|
|
177
|
+
ta.style.position = 'fixed';
|
|
178
|
+
ta.style.opacity = '0';
|
|
179
|
+
document.body.appendChild(ta);
|
|
180
|
+
ta.select();
|
|
181
|
+
document.execCommand('copy');
|
|
182
|
+
document.body.removeChild(ta);
|
|
183
|
+
done();
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
72
187
|
function ajax(method, url, data, onSuccess, onError) {
|
|
73
188
|
var xhr = new XMLHttpRequest();
|
|
74
189
|
xhr.open(method, url, true);
|
|
@@ -117,18 +232,191 @@
|
|
|
117
232
|
|
|
118
233
|
// --- Tabs ---
|
|
119
234
|
|
|
235
|
+
function activateTab(name) {
|
|
236
|
+
qsa('.mg-tab').forEach(function(t) { t.classList.remove('active'); });
|
|
237
|
+
qsa('.mg-tab-content').forEach(function(c) { c.classList.remove('active'); });
|
|
238
|
+
var btn = qs('[data-tab="' + name + '"]');
|
|
239
|
+
if (!btn) { name = 'dashboard'; btn = qs('[data-tab="dashboard"]'); }
|
|
240
|
+
btn.classList.add('active');
|
|
241
|
+
el('tab-' + name).classList.add('active');
|
|
242
|
+
history.replaceState(null, '', location.pathname + '#' + name);
|
|
243
|
+
if (name === 'dashboard') loadDashboard();
|
|
244
|
+
if (name === 'slow') loadSlowQueries();
|
|
245
|
+
if (name === 'indexes') loadDuplicateIndexes();
|
|
246
|
+
if (name === 'tables') loadTableSizes();
|
|
247
|
+
if (name === 'qstats') loadQueryStats();
|
|
248
|
+
if (name === 'unused') loadUnusedIndexes();
|
|
249
|
+
if (name === 'server') loadServerOverview();
|
|
250
|
+
}
|
|
251
|
+
|
|
120
252
|
qsa('.mg-tab').forEach(function(tab) {
|
|
121
|
-
tab.addEventListener('click', function() {
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
253
|
+
tab.addEventListener('click', function() { activateTab(tab.dataset.tab); });
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
// --- Dashboard ---
|
|
257
|
+
|
|
258
|
+
function loadDashboard() {
|
|
259
|
+
show(el('dash-loading'));
|
|
260
|
+
hide(el('dash-content')); hide(el('dash-error'));
|
|
261
|
+
// Reset previous data
|
|
262
|
+
hide(el('dash-slow-table')); hide(el('dash-slow-empty'));
|
|
263
|
+
hide(el('dash-qstats-table')); hide(el('dash-qstats-empty')); hide(el('dash-qstats-error'));
|
|
264
|
+
el('dash-slow-tbody').innerHTML = '';
|
|
265
|
+
el('dash-qstats-tbody').innerHTML = '';
|
|
266
|
+
el('dash-dup-count').textContent = '--';
|
|
267
|
+
el('dash-unused-count').textContent = '--';
|
|
268
|
+
|
|
269
|
+
var loaded = { server: false, slow: false, qstats: false, dup: false, unused: false };
|
|
270
|
+
|
|
271
|
+
function checkAllLoaded() {
|
|
272
|
+
if (!loaded.server || !loaded.slow || !loaded.qstats || !loaded.dup || !loaded.unused) return;
|
|
273
|
+
hide(el('dash-loading'));
|
|
274
|
+
show(el('dash-content'));
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Server overview
|
|
278
|
+
ajaxGet(ROUTES.server_overview, {}, function(data) {
|
|
279
|
+
if (data.error) { loaded.server = true; checkAllLoaded(); return; }
|
|
280
|
+
var s = data.server;
|
|
281
|
+
var c = data.connections;
|
|
282
|
+
var db = data.innodb;
|
|
283
|
+
var q = data.queries;
|
|
284
|
+
|
|
285
|
+
el('dash-server-info').innerHTML =
|
|
286
|
+
statRow('Version', '<code>' + escHtml(s.version) + '</code>') +
|
|
287
|
+
statRow('Uptime', escHtml(s.uptime)) +
|
|
288
|
+
statRow('Queries/sec', q.qps);
|
|
289
|
+
|
|
290
|
+
el('dash-conn-bar').innerHTML = usageBar(c.usage_pct, c.current + ' / ' + c.max + ' (' + c.usage_pct + '%)');
|
|
291
|
+
el('dash-conn-info').innerHTML =
|
|
292
|
+
statRow('Threads Running', c.threads_running) +
|
|
293
|
+
statRow('Max Used', c.max_used);
|
|
294
|
+
|
|
295
|
+
var poolUsedPct = db.buffer_pool_pages_total > 0
|
|
296
|
+
? (((db.buffer_pool_pages_total - db.buffer_pool_pages_free) / db.buffer_pool_pages_total) * 100).toFixed(1)
|
|
297
|
+
: 0;
|
|
298
|
+
el('dash-innodb-bar').innerHTML = usageBar(parseFloat(poolUsedPct), db.buffer_pool_mb + ' MB (' + poolUsedPct + '% used)');
|
|
299
|
+
el('dash-innodb-info').innerHTML =
|
|
300
|
+
statRow('Hit Rate', db.buffer_pool_hit_rate + '%') +
|
|
301
|
+
statRow('Dirty Pages', Number(db.buffer_pool_pages_dirty).toLocaleString());
|
|
302
|
+
|
|
303
|
+
var tmpBadge = q.tmp_disk_pct > 25
|
|
304
|
+
? '<span class="mg-badge mg-badge-danger">' + q.tmp_disk_pct + '%</span>'
|
|
305
|
+
: q.tmp_disk_pct + '%';
|
|
306
|
+
el('dash-query-info').innerHTML =
|
|
307
|
+
statRow('Slow Queries', Number(q.slow_queries).toLocaleString()) +
|
|
308
|
+
statRow('Tmp Disk Tables', tmpBadge);
|
|
309
|
+
|
|
310
|
+
loaded.server = true;
|
|
311
|
+
checkAllLoaded();
|
|
312
|
+
}, function() { loaded.server = true; checkAllLoaded(); });
|
|
313
|
+
|
|
314
|
+
// Top 5 slow queries
|
|
315
|
+
ajaxGet(ROUTES.slow_queries, {}, function(data) {
|
|
316
|
+
if (!data || !data.length) {
|
|
317
|
+
el('dash-slow-empty').textContent = 'No slow queries recorded.';
|
|
318
|
+
show(el('dash-slow-empty'));
|
|
319
|
+
} else {
|
|
320
|
+
var top5 = data.slice(0, 5);
|
|
321
|
+
el('dash-slow-tbody').innerHTML = top5.map(function(q) {
|
|
322
|
+
var d = q.duration_ms;
|
|
323
|
+
var cls = d >= 2000 ? 'mg-badge-danger' : d >= 1000 ? 'mg-badge-warning' : 'mg-badge-info';
|
|
324
|
+
return '<tr><td><span class="mg-badge ' + cls + '">' + d + ' ms</span></td>' +
|
|
325
|
+
'<td><small>' + escHtml(q.timestamp) + '</small></td>' +
|
|
326
|
+
'<td>' + sqlBlock(q.sql, 120) + '</td></tr>';
|
|
327
|
+
}).join('');
|
|
328
|
+
show(el('dash-slow-table'));
|
|
329
|
+
}
|
|
330
|
+
loaded.slow = true;
|
|
331
|
+
checkAllLoaded();
|
|
332
|
+
}, function() {
|
|
333
|
+
el('dash-slow-empty').textContent = 'Configure redis_url to monitor slow queries.';
|
|
334
|
+
show(el('dash-slow-empty'));
|
|
335
|
+
loaded.slow = true;
|
|
336
|
+
checkAllLoaded();
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
// Top 5 expensive queries
|
|
340
|
+
ajaxGet(ROUTES.query_stats, { sort: 'total_time', limit: 5 }, function(data) {
|
|
341
|
+
if (data.error) {
|
|
342
|
+
el('dash-qstats-error').innerHTML = '<div class="mg-text-muted">' + escHtml(data.error) + '</div>';
|
|
343
|
+
show(el('dash-qstats-error'));
|
|
344
|
+
} else if (!data.length) {
|
|
345
|
+
el('dash-qstats-empty').textContent = 'No query statistics available.';
|
|
346
|
+
show(el('dash-qstats-empty'));
|
|
347
|
+
} else {
|
|
348
|
+
el('dash-qstats-tbody').innerHTML = data.map(function(q) {
|
|
349
|
+
return '<tr>' +
|
|
350
|
+
'<td>' + sqlBlock(q.sql, 120) + '</td>' +
|
|
351
|
+
'<td class="mg-num">' + Number(q.calls).toLocaleString() + '</td>' +
|
|
352
|
+
'<td class="mg-num">' + formatDurationStyled(q.total_time_ms) + '</td>' +
|
|
353
|
+
'<td class="mg-num">' + formatDurationStyled(q.avg_time_ms) + '</td></tr>';
|
|
354
|
+
}).join('');
|
|
355
|
+
show(el('dash-qstats-table'));
|
|
356
|
+
}
|
|
357
|
+
loaded.qstats = true;
|
|
358
|
+
checkAllLoaded();
|
|
359
|
+
}, function() {
|
|
360
|
+
el('dash-qstats-empty').textContent = 'Query statistics unavailable.';
|
|
361
|
+
show(el('dash-qstats-empty'));
|
|
362
|
+
loaded.qstats = true;
|
|
363
|
+
checkAllLoaded();
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
// Duplicate indexes count
|
|
367
|
+
ajaxGet(ROUTES.duplicate_indexes, {}, function(data) {
|
|
368
|
+
var count = Array.isArray(data) ? data.length : 0;
|
|
369
|
+
var countEl = el('dash-dup-count');
|
|
370
|
+
if (count === 0) {
|
|
371
|
+
countEl.innerHTML = '<span style="color:#28a745;">✓ 0</span>';
|
|
372
|
+
} else {
|
|
373
|
+
countEl.innerHTML = '<span style="color:#dc3545;">' + count + '</span>';
|
|
374
|
+
}
|
|
375
|
+
loaded.dup = true;
|
|
376
|
+
checkAllLoaded();
|
|
377
|
+
}, function() { el('dash-dup-count').textContent = '?'; loaded.dup = true; checkAllLoaded(); });
|
|
378
|
+
|
|
379
|
+
// Unused indexes count
|
|
380
|
+
ajaxGet(ROUTES.unused_indexes, {}, function(data) {
|
|
381
|
+
var count = Array.isArray(data) ? data.length : 0;
|
|
382
|
+
var countEl = el('dash-unused-count');
|
|
383
|
+
if (count === 0) {
|
|
384
|
+
countEl.innerHTML = '<span style="color:#28a745;">✓ 0</span>';
|
|
385
|
+
} else {
|
|
386
|
+
countEl.innerHTML = '<span style="color:#dc3545;">' + count + '</span>';
|
|
387
|
+
}
|
|
388
|
+
loaded.unused = true;
|
|
389
|
+
checkAllLoaded();
|
|
390
|
+
}, function() { el('dash-unused-count').textContent = '?'; loaded.unused = true; checkAllLoaded(); });
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// Dashboard tab-jump buttons
|
|
394
|
+
document.addEventListener('click', function(e) {
|
|
395
|
+
var jumpBtn = e.target.closest('.dash-jump-tab');
|
|
396
|
+
if (jumpBtn) { activateTab(jumpBtn.dataset.target); }
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
// Restore active tab from URL hash, or default to dashboard
|
|
400
|
+
var initialTab = (location.hash || '').replace('#', '') || 'dashboard';
|
|
401
|
+
activateTab(initialTab);
|
|
402
|
+
|
|
403
|
+
// --- Query Explorer Mode Toggle ---
|
|
404
|
+
|
|
405
|
+
qsa('.qe-mode').forEach(function(btn) {
|
|
406
|
+
btn.addEventListener('click', function() {
|
|
407
|
+
qsa('.qe-mode').forEach(function(b) {
|
|
408
|
+
b.classList.remove('active');
|
|
409
|
+
b.classList.add('mg-btn-outline');
|
|
410
|
+
});
|
|
411
|
+
btn.classList.add('active');
|
|
412
|
+
btn.classList.remove('mg-btn-outline');
|
|
413
|
+
if (btn.dataset.mode === 'visual') {
|
|
414
|
+
show(el('qe-visual'));
|
|
415
|
+
hide(el('qe-sql'));
|
|
416
|
+
} else {
|
|
417
|
+
hide(el('qe-visual'));
|
|
418
|
+
show(el('qe-sql'));
|
|
419
|
+
}
|
|
132
420
|
});
|
|
133
421
|
});
|
|
134
422
|
|
|
@@ -498,13 +786,11 @@
|
|
|
498
786
|
el('slow-tbody').innerHTML = data.map(function(q) {
|
|
499
787
|
var d = q.duration_ms;
|
|
500
788
|
var cls = d >= 2000 ? 'mg-badge-danger' : d >= 1000 ? 'mg-badge-warning' : 'mg-badge-info';
|
|
501
|
-
var sqlEsc = escHtml(q.sql);
|
|
502
|
-
var sqlShort = sqlEsc.length > 200 ? sqlEsc.substring(0, 200) + '...' : sqlEsc;
|
|
503
789
|
return '<tr><td><span class="mg-badge ' + cls + '">' + d + ' ms</span></td>' +
|
|
504
790
|
'<td><small>' + escHtml(q.timestamp) + '</small></td>' +
|
|
505
|
-
'<td
|
|
506
|
-
'<td><button class="mg-btn mg-btn-outline mg-btn-sm slow-explain-btn" data-sql="' +
|
|
507
|
-
'<button class="mg-btn mg-btn-outline-secondary mg-btn-sm slow-use-btn" data-sql="' +
|
|
791
|
+
'<td>' + sqlBlock(q.sql, 200) + '</td>' +
|
|
792
|
+
'<td><button class="mg-btn mg-btn-outline mg-btn-sm slow-explain-btn" data-sql="' + escHtml(q.sql).replace(/"/g, '"') + '">Explain</button> ' +
|
|
793
|
+
'<button class="mg-btn mg-btn-outline-secondary mg-btn-sm slow-use-btn" data-sql="' + escHtml(q.sql).replace(/"/g, '"') + '">Use</button></td></tr>';
|
|
508
794
|
}).join('');
|
|
509
795
|
show(el('slow-table-wrapper'));
|
|
510
796
|
}, function() {
|
|
@@ -522,10 +808,13 @@
|
|
|
522
808
|
var useBtn = e.target.closest('.slow-use-btn');
|
|
523
809
|
if (useBtn) {
|
|
524
810
|
el('sql-input').value = useBtn.dataset.sql;
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
811
|
+
activateTab('explorer');
|
|
812
|
+
// Switch to SQL mode within Query Explorer
|
|
813
|
+
qsa('.qe-mode').forEach(function(b) { b.classList.remove('active'); b.classList.add('mg-btn-outline'); });
|
|
814
|
+
qs('.qe-mode[data-mode="sql"]').classList.add('active');
|
|
815
|
+
qs('.qe-mode[data-mode="sql"]').classList.remove('mg-btn-outline');
|
|
816
|
+
hide(el('qe-visual'));
|
|
817
|
+
show(el('qe-sql'));
|
|
529
818
|
}
|
|
530
819
|
});
|
|
531
820
|
|
|
@@ -548,10 +837,21 @@
|
|
|
548
837
|
'<td>' + d.duplicate_columns.map(function(c) { return '<code>' + escHtml(c) + '</code>'; }).join(', ') + '</td>' +
|
|
549
838
|
'<td><code>' + escHtml(d.covered_by_index) + '</code></td>' +
|
|
550
839
|
'<td>' + d.covered_by_columns.map(function(c) { return '<code>' + escHtml(c) + '</code>'; }).join(', ') + '</td>' +
|
|
551
|
-
'<td
|
|
840
|
+
'<td>' + sqlBlock(dropSql, 0) + '</td>' +
|
|
552
841
|
'</tr>';
|
|
553
842
|
}).join('');
|
|
554
843
|
show(el('dup-table-wrapper'));
|
|
844
|
+
|
|
845
|
+
// Generate migration
|
|
846
|
+
var ts = migrationTimestamp();
|
|
847
|
+
var migrationLines = ['# ' + ts + '_remove_duplicate_indexes.rb', '',
|
|
848
|
+
'class RemoveDuplicateIndexes < ActiveRecord::Migration[' + RAILS_MIGRATION_VERSION + ']', ' def change'];
|
|
849
|
+
data.forEach(function(d) {
|
|
850
|
+
migrationLines.push(' remove_index :' + d.table + ', name: :' + d.duplicate_index);
|
|
851
|
+
});
|
|
852
|
+
migrationLines.push(' end', 'end');
|
|
853
|
+
el('dup-migration-code').textContent = migrationLines.join('\n');
|
|
854
|
+
show(el('dup-migration'));
|
|
555
855
|
}, function() {
|
|
556
856
|
hide(el('dup-loading'));
|
|
557
857
|
el('dup-empty').textContent = 'Failed to scan indexes.';
|
|
@@ -560,6 +860,9 @@
|
|
|
560
860
|
}
|
|
561
861
|
|
|
562
862
|
el('dup-refresh').addEventListener('click', loadDuplicateIndexes);
|
|
863
|
+
el('dup-copy-migration').addEventListener('click', function() {
|
|
864
|
+
copyToClipboard(el('dup-migration-code').textContent, el('dup-copy-migration'));
|
|
865
|
+
});
|
|
563
866
|
|
|
564
867
|
// --- Table Sizes ---
|
|
565
868
|
|
|
@@ -571,7 +874,7 @@
|
|
|
571
874
|
|
|
572
875
|
function sizeBar(pct, color) {
|
|
573
876
|
return '<div style="background:#e9ecef;border-radius:3px;height:8px;width:100%;">' +
|
|
574
|
-
'<div style="background:' + color + ';border-radius:3px;height:8px;width:' + Math.max(pct, 1) + '%;"></div></div>';
|
|
877
|
+
'<div style="background:' + color + ';border-radius:3px;height:8px;width:' + Math.max(pct, 1) + '%;transition:width 0.3s;"></div></div>';
|
|
575
878
|
}
|
|
576
879
|
|
|
577
880
|
var PIE_COLORS = [
|
|
@@ -642,13 +945,22 @@
|
|
|
642
945
|
var pct = (t.total_mb / maxMb) * 100;
|
|
643
946
|
var color = t.total_mb >= 100 ? '#dc3545' : t.total_mb >= 10 ? '#ffc107' : '#28a745';
|
|
644
947
|
var rows = t.rows != null ? Number(t.rows).toLocaleString() : '?';
|
|
948
|
+
var fragText = t.fragmented_mb > 0 ? formatMb(t.fragmented_mb) : '<span class="mg-text-muted">\u2014</span>';
|
|
949
|
+
if (t.needs_optimize) fragText += ' <span class="mg-badge mg-badge-warning" title="Fragmentation >10% of total size">optimize</span>';
|
|
950
|
+
var updated = t.updated_at ? new Date(t.updated_at).toLocaleDateString() : '<span class="mg-text-muted">\u2014</span>';
|
|
951
|
+
var engineBadge = t.engine ? '<span class="mg-badge mg-badge-secondary">' + escHtml(t.engine) + '</span>' : '';
|
|
645
952
|
return '<tr>' +
|
|
646
|
-
'<td><strong>' + escHtml(t.table) + '</strong
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
'
|
|
650
|
-
'<td
|
|
651
|
-
'<td
|
|
953
|
+
'<td><strong>' + escHtml(t.table) + '</strong>' +
|
|
954
|
+
(t.collation ? ' <span class="mg-text-muted" style="font-size:11px">' + escHtml(t.collation) + '</span>' : '') +
|
|
955
|
+
(t.auto_increment ? ' <span class="mg-text-muted" style="font-size:11px">AI:' + Number(t.auto_increment).toLocaleString() + '</span>' : '') +
|
|
956
|
+
'</td>' +
|
|
957
|
+
'<td class="mg-num">' + rows + '</td>' +
|
|
958
|
+
'<td>' + engineBadge + '</td>' +
|
|
959
|
+
'<td class="mg-num">' + formatMb(t.data_mb) + '</td>' +
|
|
960
|
+
'<td class="mg-num">' + formatMb(t.index_mb) + '</td>' +
|
|
961
|
+
'<td class="mg-num"><strong>' + formatMb(t.total_mb) + '</strong></td>' +
|
|
962
|
+
'<td class="mg-num">' + fragText + '</td>' +
|
|
963
|
+
'<td>' + updated + '</td>' +
|
|
652
964
|
'<td>' + sizeBar(pct, color) + '</td>' +
|
|
653
965
|
'</tr>';
|
|
654
966
|
}).join('');
|
|
@@ -679,16 +991,15 @@
|
|
|
679
991
|
el('qstats-count').textContent = data.length + ' queries';
|
|
680
992
|
el('qstats-tbody').innerHTML = data.map(function(q) {
|
|
681
993
|
var ratioClass = q.rows_ratio > 100 ? 'mg-badge-danger' : q.rows_ratio > 10 ? 'mg-badge-warning' : '';
|
|
682
|
-
var sqlShort = q.sql.length > 120 ? q.sql.substring(0, 120) + '...' : q.sql;
|
|
683
994
|
return '<tr>' +
|
|
684
|
-
'<td
|
|
685
|
-
'<td
|
|
686
|
-
'<td
|
|
687
|
-
'<td
|
|
688
|
-
'<td
|
|
689
|
-
'<td
|
|
690
|
-
'<td
|
|
691
|
-
'<td
|
|
995
|
+
'<td>' + sqlBlock(q.sql, 120) + '</td>' +
|
|
996
|
+
'<td class="mg-num">' + Number(q.calls).toLocaleString() + '</td>' +
|
|
997
|
+
'<td class="mg-num">' + formatDurationStyled(q.total_time_ms) + '</td>' +
|
|
998
|
+
'<td class="mg-num">' + formatDurationStyled(q.avg_time_ms) + '</td>' +
|
|
999
|
+
'<td class="mg-num">' + formatDurationStyled(q.max_time_ms) + '</td>' +
|
|
1000
|
+
'<td class="mg-num">' + Number(q.rows_examined).toLocaleString() + '</td>' +
|
|
1001
|
+
'<td class="mg-num">' + Number(q.rows_sent).toLocaleString() + '</td>' +
|
|
1002
|
+
'<td class="mg-num">' + (ratioClass ? '<span class="mg-badge ' + ratioClass + '">' + q.rows_ratio + 'x</span>' : q.rows_ratio + 'x') + '</td>' +
|
|
692
1003
|
'</tr>';
|
|
693
1004
|
}).join('');
|
|
694
1005
|
show(el('qstats-table-wrapper'));
|
|
@@ -727,13 +1038,24 @@
|
|
|
727
1038
|
return '<tr>' +
|
|
728
1039
|
'<td><strong>' + escHtml(d.table) + '</strong></td>' +
|
|
729
1040
|
'<td><code>' + escHtml(d.index_name) + '</code></td>' +
|
|
730
|
-
'<td
|
|
731
|
-
'<td
|
|
732
|
-
'<td
|
|
733
|
-
'<td
|
|
1041
|
+
'<td class="mg-num">' + d.reads + '</td>' +
|
|
1042
|
+
'<td class="mg-num">' + Number(d.writes).toLocaleString() + '</td>' +
|
|
1043
|
+
'<td class="mg-num">' + Number(d.table_rows).toLocaleString() + '</td>' +
|
|
1044
|
+
'<td>' + sqlBlock(d.drop_sql, 0) + '</td>' +
|
|
734
1045
|
'</tr>';
|
|
735
1046
|
}).join('');
|
|
736
1047
|
show(el('unused-table-wrapper'));
|
|
1048
|
+
|
|
1049
|
+
// Generate migration
|
|
1050
|
+
var ts = migrationTimestamp();
|
|
1051
|
+
var migrationLines = ['# ' + ts + '_remove_unused_indexes.rb', '',
|
|
1052
|
+
'class RemoveUnusedIndexes < ActiveRecord::Migration[' + RAILS_MIGRATION_VERSION + ']', ' def change'];
|
|
1053
|
+
data.forEach(function(d) {
|
|
1054
|
+
migrationLines.push(' remove_index :' + d.table + ', name: :' + d.index_name);
|
|
1055
|
+
});
|
|
1056
|
+
migrationLines.push(' end', 'end');
|
|
1057
|
+
el('unused-migration-code').textContent = migrationLines.join('\n');
|
|
1058
|
+
show(el('unused-migration'));
|
|
737
1059
|
}, function(json) {
|
|
738
1060
|
hide(el('unused-loading'));
|
|
739
1061
|
el('unused-error').innerHTML = '<div class="mg-alert mg-alert-warning">' + escHtml((json && json.error) || 'Failed to load unused indexes.') + '</div>';
|
|
@@ -742,6 +1064,9 @@
|
|
|
742
1064
|
}
|
|
743
1065
|
|
|
744
1066
|
el('unused-refresh').addEventListener('click', loadUnusedIndexes);
|
|
1067
|
+
el('unused-copy-migration').addEventListener('click', function() {
|
|
1068
|
+
copyToClipboard(el('unused-migration-code').textContent, el('unused-copy-migration'));
|
|
1069
|
+
});
|
|
745
1070
|
|
|
746
1071
|
// --- Server Overview ---
|
|
747
1072
|
|
data/bin/console
CHANGED
data/config/routes.rb
CHANGED
|
Binary file
|
|
Binary file
|