mysql_genius 0.1.1 → 0.3.1

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.
Files changed (46) hide show
  1. checksums.yaml +4 -4
  2. data/.github/FUNDING.yml +5 -0
  3. data/.github/workflows/ci.yml +30 -7
  4. data/.github/workflows/publish.yml +32 -0
  5. data/.gitignore +3 -0
  6. data/.rubocop.yml +24 -0
  7. data/CHANGELOG.md +41 -0
  8. data/Gemfile +7 -2
  9. data/README.md +61 -218
  10. data/Rakefile +3 -1
  11. data/app/controllers/concerns/mysql_genius/ai_features.rb +90 -52
  12. data/app/controllers/concerns/mysql_genius/database_analysis.rb +81 -45
  13. data/app/controllers/concerns/mysql_genius/query_execution.rb +18 -16
  14. data/app/controllers/mysql_genius/base_controller.rb +3 -1
  15. data/app/controllers/mysql_genius/queries_controller.rb +19 -12
  16. data/app/services/mysql_genius/ai_client.rb +9 -2
  17. data/app/services/mysql_genius/ai_optimization_service.rb +8 -4
  18. data/app/services/mysql_genius/ai_suggestion_service.rb +5 -2
  19. data/app/views/layouts/mysql_genius/application.html.erb +147 -5
  20. data/app/views/mysql_genius/queries/_tab_dashboard.html.erb +95 -0
  21. data/app/views/mysql_genius/queries/_tab_duplicate_indexes.html.erb +11 -0
  22. data/app/views/mysql_genius/queries/_tab_query_explorer.html.erb +110 -0
  23. data/app/views/mysql_genius/queries/_tab_query_stats.html.erb +2 -2
  24. data/app/views/mysql_genius/queries/_tab_table_sizes.html.erb +7 -5
  25. data/app/views/mysql_genius/queries/_tab_unused_indexes.html.erb +11 -0
  26. data/app/views/mysql_genius/queries/index.html.erb +436 -52
  27. data/bin/console +1 -0
  28. data/config/routes.rb +2 -0
  29. data/docs/screenshots/dashboard.png +0 -0
  30. data/docs/screenshots/query_explore.png +0 -0
  31. data/docs/superpowers/plans/2026-04-08-dashboard-first-redesign.md +741 -0
  32. data/docs/superpowers/specs/2026-04-08-dashboard-first-redesign.md +87 -0
  33. data/lib/generators/mysql_genius/install/install_generator.rb +19 -0
  34. data/lib/generators/mysql_genius/install/templates/initializer.rb +56 -0
  35. data/lib/mysql_genius/configuration.rb +8 -6
  36. data/lib/mysql_genius/engine.rb +2 -0
  37. data/lib/mysql_genius/slow_query_monitor.rb +29 -25
  38. data/lib/mysql_genius/sql_validator.rb +6 -4
  39. data/lib/mysql_genius/version.rb +3 -1
  40. data/lib/mysql_genius.rb +2 -0
  41. data/mysql_genius.gemspec +9 -8
  42. metadata +23 -15
  43. data/app/views/mysql_genius/queries/_tab_sql_query.html.erb +0 -40
  44. data/app/views/mysql_genius/queries/_tab_visual_builder.html.erb +0 -61
  45. data/docs/screenshots/sql_query.png +0 -0
  46. data/docs/screenshots/visual_builder.png +0 -0
@@ -1,27 +1,32 @@
1
- <h4>&#128024; MySQLGenius</h4>
1
+ <div style="display:flex;align-items:center;justify-content:space-between;">
2
+ <h4>&#128024; 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="visual">Visual Builder</button>
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/tab_visual_builder" %>
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,168 @@
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
+ // --- Table Sorting ---
80
+
81
+ function parseSortValue(text) {
82
+ var m = text.match(/([\d.]+)\s*(s|ms|KB|MB|GB)/i);
83
+ if (m) {
84
+ var n = parseFloat(m[1]);
85
+ var unit = m[2].toLowerCase();
86
+ if (unit === 's') return n * 1000;
87
+ if (unit === 'gb') return n * 1024;
88
+ if (unit === 'mb') return n;
89
+ if (unit === 'kb') return n / 1024;
90
+ return n;
91
+ }
92
+ return parseFloat(text.replace(/[^0-9.\-]/g, '')) || 0;
93
+ }
94
+
95
+ function makeSortable(table) {
96
+ if (table.dataset.sortable) return;
97
+ table.dataset.sortable = '1';
98
+
99
+ var headers = Array.from(table.querySelectorAll('th'));
100
+ headers.forEach(function(th, colIdx) {
101
+ if (th.dataset.noSort || th.textContent.trim() === '' || th.textContent.trim() === 'Actions') return;
102
+ th.classList.add('mg-sortable');
103
+ th.addEventListener('click', function() {
104
+ var tbody = table.querySelector('tbody');
105
+ if (!tbody) return;
106
+ var rows = Array.from(tbody.querySelectorAll('tr'));
107
+ if (rows.length === 0) return;
108
+
109
+ var asc = !th.classList.contains('mg-sort-asc');
110
+ headers.forEach(function(h) { h.classList.remove('mg-sort-asc', 'mg-sort-desc'); });
111
+ th.classList.add(asc ? 'mg-sort-asc' : 'mg-sort-desc');
112
+
113
+ var isNum = th.style.textAlign === 'right' || th.classList.contains('mg-num');
114
+
115
+ rows.sort(function(a, b) {
116
+ var cellA = a.children[colIdx];
117
+ var cellB = b.children[colIdx];
118
+ if (!cellA || !cellB) return 0;
119
+ var valA = cellA.textContent.trim();
120
+ var valB = cellB.textContent.trim();
121
+
122
+ if (isNum) {
123
+ return asc ? parseSortValue(valA) - parseSortValue(valB) : parseSortValue(valB) - parseSortValue(valA);
124
+ }
125
+ return asc ? valA.localeCompare(valB) : valB.localeCompare(valA);
126
+ });
127
+
128
+ rows.forEach(function(row) { tbody.appendChild(row); });
129
+ });
130
+ });
131
+ }
132
+
133
+ // --- SQL Syntax Highlighting (single-pass tokenizer) ---
134
+
135
+ var SQL_KW_SET = {};
136
+ '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; });
137
+
138
+ var SQL_FN_SET = {};
139
+ '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; });
140
+
141
+ // Single-pass tokenizer regex: matches tokens in order of priority
142
+ // 1) single-quoted strings 2) backtick identifiers 3) numbers 4) words 5) operators 6) punctuation 7) ? placeholder 8) * star 9) whitespace 10) anything else
143
+ var SQL_TOKEN_RE = /'[^']*'|`[^`]*`|\b\d+(?:\.\d+)?\b|[A-Za-z_]\w*|\?\|>=|<=|<>|!=|[><=]|[(),;*]|\s+|./g;
144
+
145
+ function highlightSql(sql) {
146
+ if (!sql) return '';
147
+ var result = [];
148
+ var match;
149
+ SQL_TOKEN_RE.lastIndex = 0;
150
+ while ((match = SQL_TOKEN_RE.exec(sql)) !== null) {
151
+ var tok = match[0];
152
+ var ch = tok.charAt(0);
153
+
154
+ if (ch === "'") {
155
+ // String literal
156
+ result.push('<span class="mg-sql-str">' + escHtml(tok) + '</span>');
157
+ } else if (ch === '`') {
158
+ // Backtick-quoted identifier
159
+ result.push('<span class="mg-sql-tbl">' + escHtml(tok) + '</span>');
160
+ } else if (/^\d/.test(tok)) {
161
+ // Number
162
+ result.push('<span class="mg-sql-num">' + escHtml(tok) + '</span>');
163
+ } else if (/^[A-Za-z_]/.test(tok)) {
164
+ // Word: check if keyword, function, or plain identifier
165
+ var upper = tok.toUpperCase();
166
+ // Check for compound keywords (GROUP BY, ORDER BY, PARTITION BY)
167
+ var rest = sql.substring(SQL_TOKEN_RE.lastIndex);
168
+ var compoundMatch = rest.match(/^(\s+)(BY)\b/i);
169
+ if ((upper === 'GROUP' || upper === 'ORDER' || upper === 'PARTITION') && compoundMatch) {
170
+ result.push('<span class="mg-sql-kw">' + escHtml(tok) + escHtml(compoundMatch[1]) + escHtml(compoundMatch[2].toUpperCase()) + '</span>');
171
+ SQL_TOKEN_RE.lastIndex += compoundMatch[0].length;
172
+ } else if (SQL_FN_SET[upper] && rest.match(/^\s*\(/)) {
173
+ result.push('<span class="mg-sql-fn">' + escHtml(tok.toUpperCase()) + '</span>');
174
+ } else if (SQL_KW_SET[upper]) {
175
+ result.push('<span class="mg-sql-kw">' + escHtml(tok.toUpperCase()) + '</span>');
176
+ } else {
177
+ result.push(escHtml(tok));
178
+ }
179
+ } else if (tok === '?') {
180
+ result.push('<span class="mg-sql-placeholder">?</span>');
181
+ } else if (tok === '*') {
182
+ result.push('<span class="mg-sql-star">*</span>');
183
+ } else if (/^(>=|<=|<>|!=|[><=])$/.test(tok)) {
184
+ result.push('<span class="mg-sql-op">' + escHtml(tok) + '</span>');
185
+ } else if (/^[(),;]$/.test(tok)) {
186
+ result.push('<span class="mg-sql-punc">' + escHtml(tok) + '</span>');
187
+ } else {
188
+ // Whitespace or unknown
189
+ result.push(escHtml(tok));
190
+ }
191
+ }
192
+ return result.join('');
193
+ }
194
+
195
+ function sqlBlock(sql, maxLen) {
196
+ var truncated = maxLen && sql.length > maxLen;
197
+ var displaySql = truncated ? sql.substring(0, maxLen) + '...' : sql;
198
+ return '<code class="mg-sql-block" title="' + escHtml(sql) + '">' + highlightSql(displaySql) + '</code>';
199
+ }
200
+
201
+ // --- Duration Formatting with Color ---
202
+
203
+ function formatDurationStyled(ms) {
204
+ var text = formatDuration(ms);
205
+ var cls = 'mg-dur-fast';
206
+ if (ms >= 1000) cls = 'mg-dur-slow';
207
+ else if (ms >= 100) cls = 'mg-dur-moderate';
208
+ return '<span class="' + cls + '">' + text + '</span>';
209
+ }
210
+
211
+ function migrationTimestamp() {
212
+ var now = new Date();
213
+ return now.getFullYear().toString() +
214
+ ('0' + (now.getMonth() + 1)).slice(-2) +
215
+ ('0' + now.getDate()).slice(-2) +
216
+ ('0' + now.getHours()).slice(-2) +
217
+ ('0' + now.getMinutes()).slice(-2) +
218
+ ('0' + now.getSeconds()).slice(-2);
219
+ }
220
+
221
+ function copyToClipboard(text, btn) {
222
+ var done = function() {
223
+ btn.innerHTML = '&#10003; Copied';
224
+ setTimeout(function() { btn.innerHTML = '&#128203; Copy'; }, 2000);
225
+ };
226
+ if (navigator.clipboard && navigator.clipboard.writeText) {
227
+ navigator.clipboard.writeText(text).then(done);
228
+ } else {
229
+ var ta = document.createElement('textarea');
230
+ ta.value = text;
231
+ ta.style.position = 'fixed';
232
+ ta.style.opacity = '0';
233
+ document.body.appendChild(ta);
234
+ ta.select();
235
+ document.execCommand('copy');
236
+ document.body.removeChild(ta);
237
+ done();
238
+ }
239
+ }
240
+
72
241
  function ajax(method, url, data, onSuccess, onError) {
73
242
  var xhr = new XMLHttpRequest();
74
243
  xhr.open(method, url, true);
@@ -117,18 +286,191 @@
117
286
 
118
287
  // --- Tabs ---
119
288
 
289
+ function activateTab(name) {
290
+ qsa('.mg-tab').forEach(function(t) { t.classList.remove('active'); });
291
+ qsa('.mg-tab-content').forEach(function(c) { c.classList.remove('active'); });
292
+ var btn = qs('[data-tab="' + name + '"]');
293
+ if (!btn) { name = 'dashboard'; btn = qs('[data-tab="dashboard"]'); }
294
+ btn.classList.add('active');
295
+ el('tab-' + name).classList.add('active');
296
+ history.replaceState(null, '', location.pathname + '#' + name);
297
+ if (name === 'dashboard') loadDashboard();
298
+ if (name === 'slow') loadSlowQueries();
299
+ if (name === 'indexes') loadDuplicateIndexes();
300
+ if (name === 'tables') loadTableSizes();
301
+ if (name === 'qstats') loadQueryStats();
302
+ if (name === 'unused') loadUnusedIndexes();
303
+ if (name === 'server') loadServerOverview();
304
+ }
305
+
120
306
  qsa('.mg-tab').forEach(function(tab) {
121
- tab.addEventListener('click', function() {
122
- qsa('.mg-tab').forEach(function(t) { t.classList.remove('active'); });
123
- qsa('.mg-tab-content').forEach(function(c) { c.classList.remove('active'); });
124
- tab.classList.add('active');
125
- el('tab-' + tab.dataset.tab).classList.add('active');
126
- if (tab.dataset.tab === 'slow') loadSlowQueries();
127
- if (tab.dataset.tab === 'indexes') loadDuplicateIndexes();
128
- if (tab.dataset.tab === 'sizes') loadTableSizes();
129
- if (tab.dataset.tab === 'qstats') loadQueryStats();
130
- if (tab.dataset.tab === 'unused') loadUnusedIndexes();
131
- if (tab.dataset.tab === 'server') loadServerOverview();
307
+ tab.addEventListener('click', function() { activateTab(tab.dataset.tab); });
308
+ });
309
+
310
+ // --- Dashboard ---
311
+
312
+ function loadDashboard() {
313
+ show(el('dash-loading'));
314
+ hide(el('dash-content')); hide(el('dash-error'));
315
+ // Reset previous data
316
+ hide(el('dash-slow-table')); hide(el('dash-slow-empty'));
317
+ hide(el('dash-qstats-table')); hide(el('dash-qstats-empty')); hide(el('dash-qstats-error'));
318
+ el('dash-slow-tbody').innerHTML = '';
319
+ el('dash-qstats-tbody').innerHTML = '';
320
+ el('dash-dup-count').textContent = '--';
321
+ el('dash-unused-count').textContent = '--';
322
+
323
+ var loaded = { server: false, slow: false, qstats: false, dup: false, unused: false };
324
+
325
+ function checkAllLoaded() {
326
+ if (!loaded.server || !loaded.slow || !loaded.qstats || !loaded.dup || !loaded.unused) return;
327
+ hide(el('dash-loading'));
328
+ show(el('dash-content'));
329
+ }
330
+
331
+ // Server overview
332
+ ajaxGet(ROUTES.server_overview, {}, function(data) {
333
+ if (data.error) { loaded.server = true; checkAllLoaded(); return; }
334
+ var s = data.server;
335
+ var c = data.connections;
336
+ var db = data.innodb;
337
+ var q = data.queries;
338
+
339
+ el('dash-server-info').innerHTML =
340
+ statRow('Version', '<code>' + escHtml(s.version) + '</code>') +
341
+ statRow('Uptime', escHtml(s.uptime)) +
342
+ statRow('Queries/sec', q.qps);
343
+
344
+ el('dash-conn-bar').innerHTML = usageBar(c.usage_pct, c.current + ' / ' + c.max + ' (' + c.usage_pct + '%)');
345
+ el('dash-conn-info').innerHTML =
346
+ statRow('Threads Running', c.threads_running) +
347
+ statRow('Max Used', c.max_used);
348
+
349
+ var poolUsedPct = db.buffer_pool_pages_total > 0
350
+ ? (((db.buffer_pool_pages_total - db.buffer_pool_pages_free) / db.buffer_pool_pages_total) * 100).toFixed(1)
351
+ : 0;
352
+ el('dash-innodb-bar').innerHTML = usageBar(parseFloat(poolUsedPct), db.buffer_pool_mb + ' MB (' + poolUsedPct + '% used)');
353
+ el('dash-innodb-info').innerHTML =
354
+ statRow('Hit Rate', db.buffer_pool_hit_rate + '%') +
355
+ statRow('Dirty Pages', Number(db.buffer_pool_pages_dirty).toLocaleString());
356
+
357
+ var tmpBadge = q.tmp_disk_pct > 25
358
+ ? '<span class="mg-badge mg-badge-danger">' + q.tmp_disk_pct + '%</span>'
359
+ : q.tmp_disk_pct + '%';
360
+ el('dash-query-info').innerHTML =
361
+ statRow('Slow Queries', Number(q.slow_queries).toLocaleString()) +
362
+ statRow('Tmp Disk Tables', tmpBadge);
363
+
364
+ loaded.server = true;
365
+ checkAllLoaded();
366
+ }, function() { loaded.server = true; checkAllLoaded(); });
367
+
368
+ // Top 5 slow queries
369
+ ajaxGet(ROUTES.slow_queries, {}, function(data) {
370
+ if (!data || !data.length) {
371
+ el('dash-slow-empty').textContent = 'No slow queries recorded.';
372
+ show(el('dash-slow-empty'));
373
+ } else {
374
+ var top5 = data.slice(0, 5);
375
+ el('dash-slow-tbody').innerHTML = top5.map(function(q) {
376
+ var d = q.duration_ms;
377
+ var cls = d >= 2000 ? 'mg-badge-danger' : d >= 1000 ? 'mg-badge-warning' : 'mg-badge-info';
378
+ return '<tr><td><span class="mg-badge ' + cls + '">' + d + ' ms</span></td>' +
379
+ '<td><small>' + escHtml(q.timestamp) + '</small></td>' +
380
+ '<td>' + sqlBlock(q.sql, 120) + '</td></tr>';
381
+ }).join('');
382
+ show(el('dash-slow-table'));
383
+ }
384
+ loaded.slow = true;
385
+ checkAllLoaded();
386
+ }, function() {
387
+ el('dash-slow-empty').textContent = 'Configure redis_url to monitor slow queries.';
388
+ show(el('dash-slow-empty'));
389
+ loaded.slow = true;
390
+ checkAllLoaded();
391
+ });
392
+
393
+ // Top 5 expensive queries
394
+ ajaxGet(ROUTES.query_stats, { sort: 'total_time', limit: 5 }, function(data) {
395
+ if (data.error) {
396
+ el('dash-qstats-error').innerHTML = '<div class="mg-text-muted">' + escHtml(data.error) + '</div>';
397
+ show(el('dash-qstats-error'));
398
+ } else if (!data.length) {
399
+ el('dash-qstats-empty').textContent = 'No query statistics available.';
400
+ show(el('dash-qstats-empty'));
401
+ } else {
402
+ el('dash-qstats-tbody').innerHTML = data.map(function(q) {
403
+ return '<tr>' +
404
+ '<td>' + sqlBlock(q.sql, 120) + '</td>' +
405
+ '<td class="mg-num">' + Number(q.calls).toLocaleString() + '</td>' +
406
+ '<td class="mg-num">' + formatDurationStyled(q.total_time_ms) + '</td>' +
407
+ '<td class="mg-num">' + formatDurationStyled(q.avg_time_ms) + '</td></tr>';
408
+ }).join('');
409
+ show(el('dash-qstats-table'));
410
+ }
411
+ loaded.qstats = true;
412
+ checkAllLoaded();
413
+ }, function() {
414
+ el('dash-qstats-empty').textContent = 'Query statistics unavailable.';
415
+ show(el('dash-qstats-empty'));
416
+ loaded.qstats = true;
417
+ checkAllLoaded();
418
+ });
419
+
420
+ // Duplicate indexes count
421
+ ajaxGet(ROUTES.duplicate_indexes, {}, function(data) {
422
+ var count = Array.isArray(data) ? data.length : 0;
423
+ var countEl = el('dash-dup-count');
424
+ if (count === 0) {
425
+ countEl.innerHTML = '<span style="color:#28a745;">&#10003; 0</span>';
426
+ } else {
427
+ countEl.innerHTML = '<span style="color:#dc3545;">' + count + '</span>';
428
+ }
429
+ loaded.dup = true;
430
+ checkAllLoaded();
431
+ }, function() { el('dash-dup-count').textContent = '?'; loaded.dup = true; checkAllLoaded(); });
432
+
433
+ // Unused indexes count
434
+ ajaxGet(ROUTES.unused_indexes, {}, function(data) {
435
+ var count = Array.isArray(data) ? data.length : 0;
436
+ var countEl = el('dash-unused-count');
437
+ if (count === 0) {
438
+ countEl.innerHTML = '<span style="color:#28a745;">&#10003; 0</span>';
439
+ } else {
440
+ countEl.innerHTML = '<span style="color:#dc3545;">' + count + '</span>';
441
+ }
442
+ loaded.unused = true;
443
+ checkAllLoaded();
444
+ }, function() { el('dash-unused-count').textContent = '?'; loaded.unused = true; checkAllLoaded(); });
445
+ }
446
+
447
+ // Dashboard tab-jump buttons
448
+ document.addEventListener('click', function(e) {
449
+ var jumpBtn = e.target.closest('.dash-jump-tab');
450
+ if (jumpBtn) { activateTab(jumpBtn.dataset.target); }
451
+ });
452
+
453
+ // Restore active tab from URL hash, or default to dashboard
454
+ var initialTab = (location.hash || '').replace('#', '') || 'dashboard';
455
+ activateTab(initialTab);
456
+
457
+ // --- Query Explorer Mode Toggle ---
458
+
459
+ qsa('.qe-mode').forEach(function(btn) {
460
+ btn.addEventListener('click', function() {
461
+ qsa('.qe-mode').forEach(function(b) {
462
+ b.classList.remove('active');
463
+ b.classList.add('mg-btn-outline');
464
+ });
465
+ btn.classList.add('active');
466
+ btn.classList.remove('mg-btn-outline');
467
+ if (btn.dataset.mode === 'visual') {
468
+ show(el('qe-visual'));
469
+ hide(el('qe-sql'));
470
+ } else {
471
+ hide(el('qe-visual'));
472
+ show(el('qe-sql'));
473
+ }
132
474
  });
133
475
  });
134
476
 
@@ -498,15 +840,14 @@
498
840
  el('slow-tbody').innerHTML = data.map(function(q) {
499
841
  var d = q.duration_ms;
500
842
  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
843
  return '<tr><td><span class="mg-badge ' + cls + '">' + d + ' ms</span></td>' +
504
844
  '<td><small>' + escHtml(q.timestamp) + '</small></td>' +
505
- '<td><code title="' + sqlEsc + '">' + sqlShort + '</code></td>' +
506
- '<td><button class="mg-btn mg-btn-outline mg-btn-sm slow-explain-btn" data-sql="' + sqlEsc.replace(/"/g, '&quot;') + '">Explain</button> ' +
507
- '<button class="mg-btn mg-btn-outline-secondary mg-btn-sm slow-use-btn" data-sql="' + sqlEsc.replace(/"/g, '&quot;') + '">Use</button></td></tr>';
845
+ '<td>' + sqlBlock(q.sql, 200) + '</td>' +
846
+ '<td><button class="mg-btn mg-btn-outline mg-btn-sm slow-explain-btn" data-sql="' + escHtml(q.sql).replace(/"/g, '&quot;') + '">Explain</button> ' +
847
+ '<button class="mg-btn mg-btn-outline-secondary mg-btn-sm slow-use-btn" data-sql="' + escHtml(q.sql).replace(/"/g, '&quot;') + '">Use</button></td></tr>';
508
848
  }).join('');
509
849
  show(el('slow-table-wrapper'));
850
+ makeSortable(qs('#slow-table-wrapper .mg-table'));
510
851
  }, function() {
511
852
  hide(el('slow-loading'));
512
853
  el('slow-empty').textContent = 'Failed to load slow queries.';
@@ -522,10 +863,13 @@
522
863
  var useBtn = e.target.closest('.slow-use-btn');
523
864
  if (useBtn) {
524
865
  el('sql-input').value = useBtn.dataset.sql;
525
- qsa('.mg-tab').forEach(function(t) { t.classList.remove('active'); });
526
- qsa('.mg-tab-content').forEach(function(c) { c.classList.remove('active'); });
527
- qs('[data-tab="sql"]').classList.add('active');
528
- el('tab-sql').classList.add('active');
866
+ activateTab('explorer');
867
+ // Switch to SQL mode within Query Explorer
868
+ qsa('.qe-mode').forEach(function(b) { b.classList.remove('active'); b.classList.add('mg-btn-outline'); });
869
+ qs('.qe-mode[data-mode="sql"]').classList.add('active');
870
+ qs('.qe-mode[data-mode="sql"]').classList.remove('mg-btn-outline');
871
+ hide(el('qe-visual'));
872
+ show(el('qe-sql'));
529
873
  }
530
874
  });
531
875
 
@@ -548,10 +892,22 @@
548
892
  '<td>' + d.duplicate_columns.map(function(c) { return '<code>' + escHtml(c) + '</code>'; }).join(', ') + '</td>' +
549
893
  '<td><code>' + escHtml(d.covered_by_index) + '</code></td>' +
550
894
  '<td>' + d.covered_by_columns.map(function(c) { return '<code>' + escHtml(c) + '</code>'; }).join(', ') + '</td>' +
551
- '<td><code style="font-size:11px;user-select:all;cursor:pointer;" title="Click to select">' + escHtml(dropSql) + '</code></td>' +
895
+ '<td>' + sqlBlock(dropSql, 0) + '</td>' +
552
896
  '</tr>';
553
897
  }).join('');
554
898
  show(el('dup-table-wrapper'));
899
+ makeSortable(qs('#dup-table-wrapper .mg-table'));
900
+
901
+ // Generate migration
902
+ var ts = migrationTimestamp();
903
+ var migrationLines = ['# ' + ts + '_remove_duplicate_indexes.rb', '',
904
+ 'class RemoveDuplicateIndexes < ActiveRecord::Migration[' + RAILS_MIGRATION_VERSION + ']', ' def change'];
905
+ data.forEach(function(d) {
906
+ migrationLines.push(' remove_index :' + d.table + ', name: :' + d.duplicate_index);
907
+ });
908
+ migrationLines.push(' end', 'end');
909
+ el('dup-migration-code').textContent = migrationLines.join('\n');
910
+ show(el('dup-migration'));
555
911
  }, function() {
556
912
  hide(el('dup-loading'));
557
913
  el('dup-empty').textContent = 'Failed to scan indexes.';
@@ -560,6 +916,9 @@
560
916
  }
561
917
 
562
918
  el('dup-refresh').addEventListener('click', loadDuplicateIndexes);
919
+ el('dup-copy-migration').addEventListener('click', function() {
920
+ copyToClipboard(el('dup-migration-code').textContent, el('dup-copy-migration'));
921
+ });
563
922
 
564
923
  // --- Table Sizes ---
565
924
 
@@ -571,7 +930,7 @@
571
930
 
572
931
  function sizeBar(pct, color) {
573
932
  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>';
933
+ '<div style="background:' + color + ';border-radius:3px;height:8px;width:' + Math.max(pct, 1) + '%;transition:width 0.3s;"></div></div>';
575
934
  }
576
935
 
577
936
  var PIE_COLORS = [
@@ -642,17 +1001,27 @@
642
1001
  var pct = (t.total_mb / maxMb) * 100;
643
1002
  var color = t.total_mb >= 100 ? '#dc3545' : t.total_mb >= 10 ? '#ffc107' : '#28a745';
644
1003
  var rows = t.rows != null ? Number(t.rows).toLocaleString() : '?';
1004
+ var fragText = t.fragmented_mb > 0 ? formatMb(t.fragmented_mb) : '<span class="mg-text-muted">\u2014</span>';
1005
+ if (t.needs_optimize) fragText += ' <span class="mg-badge mg-badge-warning" title="Fragmentation >10% of total size">optimize</span>';
1006
+ var updated = t.updated_at ? new Date(t.updated_at).toLocaleDateString() : '<span class="mg-text-muted">\u2014</span>';
1007
+ var engineBadge = t.engine ? '<span class="mg-badge mg-badge-secondary">' + escHtml(t.engine) + '</span>' : '';
645
1008
  return '<tr>' +
646
- '<td><strong>' + escHtml(t.table) + '</strong></td>' +
647
- '<td style="text-align:right">' + rows + '</td>' +
648
- '<td style="text-align:right">' + formatMb(t.data_mb) + '</td>' +
649
- '<td style="text-align:right">' + formatMb(t.index_mb) + '</td>' +
650
- '<td style="text-align:right"><strong>' + formatMb(t.total_mb) + '</strong></td>' +
651
- '<td style="text-align:right">' + (t.fragmented_mb > 0 ? formatMb(t.fragmented_mb) : '-') + '</td>' +
1009
+ '<td><strong>' + escHtml(t.table) + '</strong>' +
1010
+ (t.collation ? ' <span class="mg-text-muted" style="font-size:11px">' + escHtml(t.collation) + '</span>' : '') +
1011
+ (t.auto_increment ? ' <span class="mg-text-muted" style="font-size:11px">AI:' + Number(t.auto_increment).toLocaleString() + '</span>' : '') +
1012
+ '</td>' +
1013
+ '<td class="mg-num">' + rows + '</td>' +
1014
+ '<td>' + engineBadge + '</td>' +
1015
+ '<td class="mg-num">' + formatMb(t.data_mb) + '</td>' +
1016
+ '<td class="mg-num">' + formatMb(t.index_mb) + '</td>' +
1017
+ '<td class="mg-num"><strong>' + formatMb(t.total_mb) + '</strong></td>' +
1018
+ '<td class="mg-num">' + fragText + '</td>' +
1019
+ '<td>' + updated + '</td>' +
652
1020
  '<td>' + sizeBar(pct, color) + '</td>' +
653
1021
  '</tr>';
654
1022
  }).join('');
655
1023
  show(el('sizes-table-wrapper'));
1024
+ makeSortable(qs('#sizes-table-wrapper .mg-table'));
656
1025
  }, function() {
657
1026
  hide(el('sizes-loading'));
658
1027
  el('sizes-total').textContent = 'Failed to load';
@@ -679,19 +1048,19 @@
679
1048
  el('qstats-count').textContent = data.length + ' queries';
680
1049
  el('qstats-tbody').innerHTML = data.map(function(q) {
681
1050
  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
1051
  return '<tr>' +
684
- '<td><code style="font-size:11px;word-break:break-all;" title="' + escHtml(q.sql) + '">' + escHtml(sqlShort) + '</code></td>' +
685
- '<td style="text-align:right">' + Number(q.calls).toLocaleString() + '</td>' +
686
- '<td style="text-align:right">' + formatDuration(q.total_time_ms) + '</td>' +
687
- '<td style="text-align:right">' + formatDuration(q.avg_time_ms) + '</td>' +
688
- '<td style="text-align:right">' + formatDuration(q.max_time_ms) + '</td>' +
689
- '<td style="text-align:right">' + Number(q.rows_examined).toLocaleString() + '</td>' +
690
- '<td style="text-align:right">' + Number(q.rows_sent).toLocaleString() + '</td>' +
691
- '<td style="text-align:right">' + (ratioClass ? '<span class="mg-badge ' + ratioClass + '">' + q.rows_ratio + 'x</span>' : q.rows_ratio + 'x') + '</td>' +
1052
+ '<td>' + sqlBlock(q.sql, 120) + '</td>' +
1053
+ '<td class="mg-num">' + Number(q.calls).toLocaleString() + '</td>' +
1054
+ '<td class="mg-num">' + formatDurationStyled(q.total_time_ms) + '</td>' +
1055
+ '<td class="mg-num">' + formatDurationStyled(q.avg_time_ms) + '</td>' +
1056
+ '<td class="mg-num">' + formatDurationStyled(q.max_time_ms) + '</td>' +
1057
+ '<td class="mg-num">' + Number(q.rows_examined).toLocaleString() + '</td>' +
1058
+ '<td class="mg-num">' + Number(q.rows_sent).toLocaleString() + '</td>' +
1059
+ '<td class="mg-num">' + (ratioClass ? '<span class="mg-badge ' + ratioClass + '">' + q.rows_ratio + 'x</span>' : q.rows_ratio + 'x') + '</td>' +
692
1060
  '</tr>';
693
1061
  }).join('');
694
1062
  show(el('qstats-table-wrapper'));
1063
+ makeSortable(qs('#qstats-table-wrapper .mg-table'));
695
1064
  }, function(json) {
696
1065
  hide(el('qstats-loading'));
697
1066
  el('qstats-error').innerHTML = '<div class="mg-alert mg-alert-warning">' + escHtml((json && json.error) || 'Failed to load query stats.') + '</div>';
@@ -727,13 +1096,25 @@
727
1096
  return '<tr>' +
728
1097
  '<td><strong>' + escHtml(d.table) + '</strong></td>' +
729
1098
  '<td><code>' + escHtml(d.index_name) + '</code></td>' +
730
- '<td style="text-align:right">' + d.reads + '</td>' +
731
- '<td style="text-align:right">' + Number(d.writes).toLocaleString() + '</td>' +
732
- '<td style="text-align:right">' + Number(d.table_rows).toLocaleString() + '</td>' +
733
- '<td><code style="font-size:11px;user-select:all;cursor:pointer;" title="Click to select">' + escHtml(d.drop_sql) + '</code></td>' +
1099
+ '<td class="mg-num">' + d.reads + '</td>' +
1100
+ '<td class="mg-num">' + Number(d.writes).toLocaleString() + '</td>' +
1101
+ '<td class="mg-num">' + Number(d.table_rows).toLocaleString() + '</td>' +
1102
+ '<td>' + sqlBlock(d.drop_sql, 0) + '</td>' +
734
1103
  '</tr>';
735
1104
  }).join('');
736
1105
  show(el('unused-table-wrapper'));
1106
+ makeSortable(qs('#unused-table-wrapper .mg-table'));
1107
+
1108
+ // Generate migration
1109
+ var ts = migrationTimestamp();
1110
+ var migrationLines = ['# ' + ts + '_remove_unused_indexes.rb', '',
1111
+ 'class RemoveUnusedIndexes < ActiveRecord::Migration[' + RAILS_MIGRATION_VERSION + ']', ' def change'];
1112
+ data.forEach(function(d) {
1113
+ migrationLines.push(' remove_index :' + d.table + ', name: :' + d.index_name);
1114
+ });
1115
+ migrationLines.push(' end', 'end');
1116
+ el('unused-migration-code').textContent = migrationLines.join('\n');
1117
+ show(el('unused-migration'));
737
1118
  }, function(json) {
738
1119
  hide(el('unused-loading'));
739
1120
  el('unused-error').innerHTML = '<div class="mg-alert mg-alert-warning">' + escHtml((json && json.error) || 'Failed to load unused indexes.') + '</div>';
@@ -742,6 +1123,9 @@
742
1123
  }
743
1124
 
744
1125
  el('unused-refresh').addEventListener('click', loadUnusedIndexes);
1126
+ el('unused-copy-migration').addEventListener('click', function() {
1127
+ copyToClipboard(el('unused-migration-code').textContent, el('unused-copy-migration'));
1128
+ });
745
1129
 
746
1130
  // --- Server Overview ---
747
1131
 
data/bin/console CHANGED
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
2
3
 
3
4
  require "bundler/setup"
4
5
  require "mysql_genius"
data/config/routes.rb CHANGED
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  MysqlGenius::Engine.routes.draw do
2
4
  root to: "queries#index"
3
5
 
Binary file
Binary file