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.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/.github/FUNDING.yml +5 -0
  3. data/.github/workflows/ci.yml +30 -7
  4. data/.gitignore +3 -0
  5. data/.rubocop.yml +24 -0
  6. data/CHANGELOG.md +32 -0
  7. data/Gemfile +7 -2
  8. data/README.md +50 -38
  9. data/Rakefile +3 -1
  10. data/app/controllers/concerns/mysql_genius/ai_features.rb +90 -52
  11. data/app/controllers/concerns/mysql_genius/database_analysis.rb +73 -45
  12. data/app/controllers/concerns/mysql_genius/query_execution.rb +18 -16
  13. data/app/controllers/mysql_genius/base_controller.rb +3 -1
  14. data/app/controllers/mysql_genius/queries_controller.rb +19 -12
  15. data/app/services/mysql_genius/ai_client.rb +10 -3
  16. data/app/services/mysql_genius/ai_optimization_service.rb +8 -4
  17. data/app/services/mysql_genius/ai_suggestion_service.rb +6 -3
  18. data/app/views/layouts/mysql_genius/application.html.erb +141 -5
  19. data/app/views/mysql_genius/queries/_tab_dashboard.html.erb +95 -0
  20. data/app/views/mysql_genius/queries/_tab_duplicate_indexes.html.erb +11 -0
  21. data/app/views/mysql_genius/queries/_tab_query_explorer.html.erb +110 -0
  22. data/app/views/mysql_genius/queries/_tab_table_sizes.html.erb +6 -4
  23. data/app/views/mysql_genius/queries/_tab_unused_indexes.html.erb +11 -0
  24. data/app/views/mysql_genius/queries/index.html.erb +377 -52
  25. data/bin/console +1 -0
  26. data/config/routes.rb +2 -0
  27. data/docs/screenshots/dashboard.png +0 -0
  28. data/docs/screenshots/query_explore.png +0 -0
  29. data/docs/superpowers/plans/2026-04-08-dashboard-first-redesign.md +741 -0
  30. data/docs/superpowers/specs/2026-04-08-dashboard-first-redesign.md +87 -0
  31. data/lib/generators/mysql_genius/install/install_generator.rb +19 -0
  32. data/lib/generators/mysql_genius/install/templates/initializer.rb +56 -0
  33. data/lib/mysql_genius/configuration.rb +8 -6
  34. data/lib/mysql_genius/engine.rb +2 -0
  35. data/lib/mysql_genius/slow_query_monitor.rb +30 -25
  36. data/lib/mysql_genius/sql_validator.rb +6 -4
  37. data/lib/mysql_genius/version.rb +3 -1
  38. data/lib/mysql_genius.rb +2 -0
  39. data/mysql_genius.gemspec +9 -8
  40. metadata +31 -15
  41. data/app/views/mysql_genius/queries/_tab_sql_query.html.erb +0 -40
  42. data/app/views/mysql_genius/queries/_tab_visual_builder.html.erb +0 -61
  43. data/docs/screenshots/sql_query.png +0 -0
  44. 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,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 = '&#10003; Copied';
170
+ setTimeout(function() { btn.innerHTML = '&#128203; 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
- 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();
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;">&#10003; 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;">&#10003; 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><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>';
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, '&quot;') + '">Explain</button> ' +
793
+ '<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
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
- 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');
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><code style="font-size:11px;user-select:all;cursor:pointer;" title="Click to select">' + escHtml(dropSql) + '</code></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></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>' +
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><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>' +
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 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>' +
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
@@ -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