sql_genius 0.9.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 (86) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +195 -0
  3. data/LICENSE.txt +65 -0
  4. data/README.md +178 -0
  5. data/Rakefile +8 -0
  6. data/app/controllers/concerns/sql_genius/ai_features.rb +332 -0
  7. data/app/controllers/concerns/sql_genius/database_analysis.rb +67 -0
  8. data/app/controllers/concerns/sql_genius/query_execution.rb +87 -0
  9. data/app/controllers/concerns/sql_genius/shared_view_helpers.rb +76 -0
  10. data/app/controllers/sql_genius/base_controller.rb +29 -0
  11. data/app/controllers/sql_genius/queries_controller.rb +94 -0
  12. data/app/views/layouts/sql_genius/application.html.erb +285 -0
  13. data/config/routes.rb +34 -0
  14. data/docs/guides/ai-features.md +115 -0
  15. data/docs/guides/getting-started-rails.md +118 -0
  16. data/docs/guides/ssh-tunnel-connections.md +151 -0
  17. data/docs/screenshots/ai_tools.png +0 -0
  18. data/docs/screenshots/dashboard.png +0 -0
  19. data/docs/screenshots/duplicate_indexes.png +0 -0
  20. data/docs/screenshots/query_explore.png +0 -0
  21. data/docs/screenshots/query_stats.png +0 -0
  22. data/docs/screenshots/server.png +0 -0
  23. data/docs/screenshots/table_sizes.png +0 -0
  24. data/lib/generators/sql_genius/install/install_generator.rb +19 -0
  25. data/lib/generators/sql_genius/install/templates/initializer.rb +56 -0
  26. data/lib/sql_genius/configuration.rb +114 -0
  27. data/lib/sql_genius/core/ai/client.rb +155 -0
  28. data/lib/sql_genius/core/ai/config.rb +47 -0
  29. data/lib/sql_genius/core/ai/connection_advisor.rb +96 -0
  30. data/lib/sql_genius/core/ai/describe_query.rb +41 -0
  31. data/lib/sql_genius/core/ai/dialect_hints.rb +35 -0
  32. data/lib/sql_genius/core/ai/index_advisor.rb +43 -0
  33. data/lib/sql_genius/core/ai/index_planner.rb +91 -0
  34. data/lib/sql_genius/core/ai/innodb_interpreter.rb +78 -0
  35. data/lib/sql_genius/core/ai/migration_risk.rb +51 -0
  36. data/lib/sql_genius/core/ai/optimization.rb +81 -0
  37. data/lib/sql_genius/core/ai/pattern_grouper.rb +94 -0
  38. data/lib/sql_genius/core/ai/rewrite_query.rb +51 -0
  39. data/lib/sql_genius/core/ai/schema_context_builder.rb +82 -0
  40. data/lib/sql_genius/core/ai/schema_review.rb +46 -0
  41. data/lib/sql_genius/core/ai/suggestion.rb +74 -0
  42. data/lib/sql_genius/core/ai/variable_reviewer.rb +113 -0
  43. data/lib/sql_genius/core/ai/workload_digest.rb +86 -0
  44. data/lib/sql_genius/core/analysis/columns.rb +63 -0
  45. data/lib/sql_genius/core/analysis/duplicate_indexes.rb +85 -0
  46. data/lib/sql_genius/core/analysis/query_history.rb +50 -0
  47. data/lib/sql_genius/core/analysis/query_stats.rb +76 -0
  48. data/lib/sql_genius/core/analysis/server_overview.rb +294 -0
  49. data/lib/sql_genius/core/analysis/stats_collector.rb +118 -0
  50. data/lib/sql_genius/core/analysis/stats_history.rb +42 -0
  51. data/lib/sql_genius/core/analysis/table_sizes.rb +52 -0
  52. data/lib/sql_genius/core/analysis/unused_indexes.rb +62 -0
  53. data/lib/sql_genius/core/column_definition.rb +30 -0
  54. data/lib/sql_genius/core/connection/active_record_adapter.rb +75 -0
  55. data/lib/sql_genius/core/connection/fake_adapter.rb +114 -0
  56. data/lib/sql_genius/core/connection.rb +37 -0
  57. data/lib/sql_genius/core/execution_result.rb +27 -0
  58. data/lib/sql_genius/core/index_definition.rb +23 -0
  59. data/lib/sql_genius/core/query_builders/mysql.rb +169 -0
  60. data/lib/sql_genius/core/query_builders/postgresql.rb +185 -0
  61. data/lib/sql_genius/core/query_builders.rb +27 -0
  62. data/lib/sql_genius/core/query_explainer.rb +113 -0
  63. data/lib/sql_genius/core/query_runner/config.rb +21 -0
  64. data/lib/sql_genius/core/query_runner.rb +123 -0
  65. data/lib/sql_genius/core/result.rb +43 -0
  66. data/lib/sql_genius/core/server_info.rb +54 -0
  67. data/lib/sql_genius/core/sql_validator.rb +149 -0
  68. data/lib/sql_genius/core/views/sql_genius/queries/_shared_results.html.erb +59 -0
  69. data/lib/sql_genius/core/views/sql_genius/queries/_tab_ai_tools.html.erb +43 -0
  70. data/lib/sql_genius/core/views/sql_genius/queries/_tab_dashboard.html.erb +97 -0
  71. data/lib/sql_genius/core/views/sql_genius/queries/_tab_duplicate_indexes.html.erb +35 -0
  72. data/lib/sql_genius/core/views/sql_genius/queries/_tab_query_explorer.html.erb +110 -0
  73. data/lib/sql_genius/core/views/sql_genius/queries/_tab_query_stats.html.erb +43 -0
  74. data/lib/sql_genius/core/views/sql_genius/queries/_tab_server.html.erb +59 -0
  75. data/lib/sql_genius/core/views/sql_genius/queries/_tab_slow_queries.html.erb +17 -0
  76. data/lib/sql_genius/core/views/sql_genius/queries/_tab_table_sizes.html.erb +33 -0
  77. data/lib/sql_genius/core/views/sql_genius/queries/_tab_unused_indexes.html.erb +54 -0
  78. data/lib/sql_genius/core/views/sql_genius/queries/dashboard.html.erb +1826 -0
  79. data/lib/sql_genius/core/views/sql_genius/queries/query_detail.html.erb +465 -0
  80. data/lib/sql_genius/core.rb +72 -0
  81. data/lib/sql_genius/engine.rb +31 -0
  82. data/lib/sql_genius/slow_query_monitor.rb +43 -0
  83. data/lib/sql_genius/version.rb +5 -0
  84. data/lib/sql_genius.rb +29 -0
  85. data/sql_genius.gemspec +47 -0
  86. metadata +171 -0
@@ -0,0 +1,1826 @@
1
+ <% if capability?(:standalone_header) %>
2
+ <div style="display:flex;align-items:center;justify-content:space-between;">
3
+ <h4>&#128024; SQLGenius</h4>
4
+ <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';})()">
5
+ <script>document.write(document.documentElement.getAttribute('data-theme')==='dark'?'\u2600\uFE0F':'\uD83C\uDF19')</script>
6
+ </button>
7
+ </div>
8
+ <% end %>
9
+
10
+ <div class="mg-tabs">
11
+ <button class="mg-tab active" data-tab="dashboard">Dashboard</button>
12
+ <% if capability?(:slow_queries) %>
13
+ <button class="mg-tab" data-tab="slow">Slow Queries</button>
14
+ <% end %>
15
+ <button class="mg-tab" data-tab="qstats">Query Stats</button>
16
+ <button class="mg-tab" data-tab="server">Server</button>
17
+ <button class="mg-tab" data-tab="tables">Tables</button>
18
+ <button class="mg-tab" data-tab="unused">Unused Indexes</button>
19
+ <button class="mg-tab" data-tab="indexes">Duplicate Indexes</button>
20
+ <button class="mg-tab" data-tab="explorer">Query Explorer</button>
21
+ <% if @ai_enabled %>
22
+ <button class="mg-tab" data-tab="aitools">AI Tools</button>
23
+ <% end %>
24
+ </div>
25
+
26
+ <%= render_partial(:tab_dashboard) %>
27
+ <% if capability?(:slow_queries) %>
28
+ <%= render_partial(:tab_slow_queries) %>
29
+ <% end %>
30
+ <%= render_partial(:tab_query_stats) %>
31
+ <%= render_partial(:tab_server) %>
32
+ <%= render_partial(:tab_table_sizes) %>
33
+ <%= render_partial(:tab_unused_indexes) %>
34
+ <%= render_partial(:tab_duplicate_indexes) %>
35
+ <%= render_partial(:tab_query_explorer) %>
36
+ <% if @ai_enabled %>
37
+ <%= render_partial(:tab_ai_tools) %>
38
+ <% end %>
39
+
40
+ <%= render_partial(:shared_results) %>
41
+
42
+ <script>
43
+ (function() {
44
+ "use strict";
45
+
46
+ var ROUTES = {
47
+ columns: '<%= path_for(:columns) %>',
48
+ execute: '<%= path_for(:execute) %>',
49
+ explain: '<%= path_for(:explain) %>',
50
+ suggest: '<%= path_for(:suggest) %>',
51
+ optimize: '<%= path_for(:optimize) %>',
52
+ slow_queries: '<%= path_for(:slow_queries) %>',
53
+ duplicate_indexes: '<%= path_for(:duplicate_indexes) %>',
54
+ table_sizes: '<%= path_for(:table_sizes) %>',
55
+ query_stats: '<%= path_for(:query_stats) %>',
56
+ unused_indexes: '<%= path_for(:unused_indexes) %>',
57
+ server_overview: '<%= path_for(:server_overview) %>',
58
+ describe_query: '<%= path_for(:describe_query) %>',
59
+ schema_review: '<%= path_for(:schema_review) %>',
60
+ rewrite_query: '<%= path_for(:rewrite_query) %>',
61
+ index_advisor: '<%= path_for(:index_advisor) %>',
62
+ anomaly_detection: '<%= path_for(:anomaly_detection) %>',
63
+ root_cause: '<%= path_for(:root_cause) %>',
64
+ migration_risk: '<%= path_for(:migration_risk) %>',
65
+ variable_review: '<%= path_for(:variable_review) %>',
66
+ connection_advisor: '<%= path_for(:connection_advisor) %>',
67
+ workload_digest: '<%= path_for(:workload_digest) %>',
68
+ innodb_health: '<%= path_for(:innodb_health) %>',
69
+ index_planner: '<%= path_for(:index_planner) %>',
70
+ pattern_grouper: '<%= path_for(:pattern_grouper) %>',
71
+ query_detail: '<%= path_for(:query_detail_prefix) %>'
72
+ };
73
+
74
+ var IDENTIFIER_QUOTE = String.fromCharCode(<%= (@identifier_quote_char || "`").ord %>);
75
+ var RAILS_MIGRATION_VERSION = '<%= @framework_version_major %>.<%= @framework_version_minor %>';
76
+
77
+ var csrfMeta = document.querySelector('meta[name="csrf-token"]');
78
+ var csrfToken = csrfMeta ? csrfMeta.getAttribute('content') : '';
79
+ var currentColumns = [];
80
+ var columnTypeMap = {};
81
+ var lastExplainSql = '';
82
+ var lastExplainRows = [];
83
+
84
+ // --- Helpers ---
85
+
86
+ function el(id) { return document.getElementById(id); }
87
+ function qs(sel, parent) { return (parent || document).querySelector(sel); }
88
+ function qsa(sel, parent) { return Array.from((parent || document).querySelectorAll(sel)); }
89
+ function show(e) { e.classList.remove('mg-hidden'); }
90
+ function hide(e) { e.classList.add('mg-hidden'); }
91
+ function escHtml(s) { var d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
92
+ function quoteIdentifier(name) {
93
+ var quote = IDENTIFIER_QUOTE || '`';
94
+ var identifier = String(name);
95
+ if (quote === '[') return '[' + identifier.split(']').join(']]') + ']';
96
+ return quote + identifier.split(quote).join(quote + quote) + quote;
97
+ }
98
+
99
+ function formatBytes(bytes) {
100
+ if (bytes === null || bytes === undefined) return '—';
101
+ var n = Number(bytes);
102
+ if (!isFinite(n) || n <= 0) return '0 B';
103
+ var units = ['B', 'KB', 'MB', 'GB', 'TB'];
104
+ var i = Math.min(units.length - 1, Math.floor(Math.log(n) / Math.log(1024)));
105
+ return (n / Math.pow(1024, i)).toFixed(i === 0 ? 0 : 1) + ' ' + units[i];
106
+ }
107
+
108
+ function formatRelativeTime(isoString) {
109
+ if (!isoString) return null;
110
+ var then = new Date(isoString);
111
+ if (isNaN(then.getTime())) return null;
112
+ var diffMs = Date.now() - then.getTime();
113
+ if (diffMs < 0) diffMs = 0;
114
+ var seconds = Math.floor(diffMs / 1000);
115
+ if (seconds < 60) return seconds + 's ago';
116
+ var minutes = Math.floor(seconds / 60);
117
+ if (minutes < 60) return minutes + 'm ago';
118
+ var hours = Math.floor(minutes / 60);
119
+ if (hours < 48) return hours + 'h ago';
120
+ var days = Math.floor(hours / 24);
121
+ return days + 'd ago';
122
+ }
123
+
124
+ function renderUnusedStatsContext(data) {
125
+ var ctx = el('unused-stats-context');
126
+ if (!ctx) return;
127
+ var parts = [];
128
+ if (data && data.stats_reset_at) {
129
+ var rel = formatRelativeTime(data.stats_reset_at);
130
+ if (rel) parts.push('Stats last reset <strong>' + escHtml(rel) + '</strong>');
131
+ }
132
+ var threshold = data && (data.min_scans || data.min_scans === 0) ? data.min_scans : null;
133
+ if (threshold !== null) {
134
+ parts.push('threshold: scans &le; <strong>' + threshold + '</strong>');
135
+ }
136
+ ctx.innerHTML = parts.length ? parts.join(' &middot; ') : '';
137
+ if (parts.length) show(ctx); else hide(ctx);
138
+ }
139
+
140
+ // --- Table Sorting ---
141
+
142
+ function parseSortValue(text) {
143
+ var m = text.match(/([\d.]+)\s*(s|ms|KB|MB|GB)/i);
144
+ if (m) {
145
+ var n = parseFloat(m[1]);
146
+ var unit = m[2].toLowerCase();
147
+ if (unit === 's') return n * 1000;
148
+ if (unit === 'gb') return n * 1024;
149
+ if (unit === 'mb') return n;
150
+ if (unit === 'kb') return n / 1024;
151
+ return n;
152
+ }
153
+ return parseFloat(text.replace(/[^0-9.\-]/g, '')) || 0;
154
+ }
155
+
156
+ function makeSortable(table) {
157
+ if (table.dataset.sortable) return;
158
+ table.dataset.sortable = '1';
159
+
160
+ var headers = Array.from(table.querySelectorAll('th'));
161
+ headers.forEach(function(th, colIdx) {
162
+ if (th.dataset.noSort || th.textContent.trim() === '' || th.textContent.trim() === 'Actions') return;
163
+ th.classList.add('mg-sortable');
164
+ th.addEventListener('click', function() {
165
+ var tbody = table.querySelector('tbody');
166
+ if (!tbody) return;
167
+ var rows = Array.from(tbody.querySelectorAll('tr'));
168
+ if (rows.length === 0) return;
169
+
170
+ var asc = !th.classList.contains('mg-sort-asc');
171
+ headers.forEach(function(h) { h.classList.remove('mg-sort-asc', 'mg-sort-desc'); });
172
+ th.classList.add(asc ? 'mg-sort-asc' : 'mg-sort-desc');
173
+
174
+ var isNum = th.style.textAlign === 'right' || th.classList.contains('mg-num');
175
+
176
+ rows.sort(function(a, b) {
177
+ var cellA = a.children[colIdx];
178
+ var cellB = b.children[colIdx];
179
+ if (!cellA || !cellB) return 0;
180
+ var valA = cellA.textContent.trim();
181
+ var valB = cellB.textContent.trim();
182
+
183
+ if (isNum) {
184
+ return asc ? parseSortValue(valA) - parseSortValue(valB) : parseSortValue(valB) - parseSortValue(valA);
185
+ }
186
+ return asc ? valA.localeCompare(valB) : valB.localeCompare(valA);
187
+ });
188
+
189
+ rows.forEach(function(row) { tbody.appendChild(row); });
190
+ });
191
+ });
192
+ }
193
+
194
+ // --- SQL Syntax Highlighting (single-pass tokenizer) ---
195
+
196
+ var SQL_KW_SET = {};
197
+ '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; });
198
+
199
+ var SQL_FN_SET = {};
200
+ '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; });
201
+
202
+ // Single-pass tokenizer regex: matches tokens in order of priority
203
+ // 1) single-quoted strings 2) quoted identifiers 3) numbers 4) words 5) operators 6) punctuation 7) ? placeholder 8) * star 9) whitespace 10) anything else
204
+ var SQL_TOKEN_RE = /'[^']*'|`[^`]*`|"(?:""|[^"])*"|\b\d+(?:\.\d+)?\b|[A-Za-z_]\w*|\?|>=|<=|<>|!=|[><=]|[(),;*]|\s+|./g;
205
+
206
+ function highlightSql(sql) {
207
+ if (!sql) return '';
208
+ var result = [];
209
+ var match;
210
+ SQL_TOKEN_RE.lastIndex = 0;
211
+ while ((match = SQL_TOKEN_RE.exec(sql)) !== null) {
212
+ var tok = match[0];
213
+ var ch = tok.charAt(0);
214
+
215
+ if (ch === "'") {
216
+ // String literal
217
+ result.push('<span class="mg-sql-str">' + escHtml(tok) + '</span>');
218
+ } else if (ch === '`' || ch === '"') {
219
+ // Quoted identifier
220
+ result.push('<span class="mg-sql-tbl">' + escHtml(tok) + '</span>');
221
+ } else if (/^\d/.test(tok)) {
222
+ // Number
223
+ result.push('<span class="mg-sql-num">' + escHtml(tok) + '</span>');
224
+ } else if (/^[A-Za-z_]/.test(tok)) {
225
+ // Word: check if keyword, function, or plain identifier
226
+ var upper = tok.toUpperCase();
227
+ // Check for compound keywords (GROUP BY, ORDER BY, PARTITION BY)
228
+ var rest = sql.substring(SQL_TOKEN_RE.lastIndex);
229
+ var compoundMatch = rest.match(/^(\s+)(BY)\b/i);
230
+ if ((upper === 'GROUP' || upper === 'ORDER' || upper === 'PARTITION') && compoundMatch) {
231
+ result.push('<span class="mg-sql-kw">' + escHtml(tok) + escHtml(compoundMatch[1]) + escHtml(compoundMatch[2].toUpperCase()) + '</span>');
232
+ SQL_TOKEN_RE.lastIndex += compoundMatch[0].length;
233
+ } else if (SQL_FN_SET[upper] && rest.match(/^\s*\(/)) {
234
+ result.push('<span class="mg-sql-fn">' + escHtml(tok.toUpperCase()) + '</span>');
235
+ } else if (SQL_KW_SET[upper]) {
236
+ result.push('<span class="mg-sql-kw">' + escHtml(tok.toUpperCase()) + '</span>');
237
+ } else {
238
+ result.push(escHtml(tok));
239
+ }
240
+ } else if (tok === '?') {
241
+ result.push('<span class="mg-sql-placeholder">?</span>');
242
+ } else if (tok === '*') {
243
+ result.push('<span class="mg-sql-star">*</span>');
244
+ } else if (/^(>=|<=|<>|!=|[><=])$/.test(tok)) {
245
+ result.push('<span class="mg-sql-op">' + escHtml(tok) + '</span>');
246
+ } else if (/^[(),;]$/.test(tok)) {
247
+ result.push('<span class="mg-sql-punc">' + escHtml(tok) + '</span>');
248
+ } else {
249
+ // Whitespace or unknown
250
+ result.push(escHtml(tok));
251
+ }
252
+ }
253
+ return result.join('');
254
+ }
255
+
256
+ function sqlBlock(sql, maxLen) {
257
+ var truncated = maxLen && sql.length > maxLen;
258
+ var displaySql = truncated ? sql.substring(0, maxLen) + '...' : sql;
259
+ return '<code class="mg-sql-block" title="' + escHtml(sql) + '">' + highlightSql(displaySql) + '</code>';
260
+ }
261
+
262
+ // --- Duration Formatting with Color ---
263
+
264
+ function formatDurationStyled(ms) {
265
+ var text = formatDuration(ms);
266
+ var cls = 'mg-dur-fast';
267
+ if (ms >= 1000) cls = 'mg-dur-slow';
268
+ else if (ms >= 100) cls = 'mg-dur-moderate';
269
+ return '<span class="' + cls + '">' + text + '</span>';
270
+ }
271
+
272
+ function migrationTimestamp() {
273
+ var now = new Date();
274
+ return now.getFullYear().toString() +
275
+ ('0' + (now.getMonth() + 1)).slice(-2) +
276
+ ('0' + now.getDate()).slice(-2) +
277
+ ('0' + now.getHours()).slice(-2) +
278
+ ('0' + now.getMinutes()).slice(-2) +
279
+ ('0' + now.getSeconds()).slice(-2);
280
+ }
281
+
282
+ function copyToClipboard(text, btn) {
283
+ var done = function() {
284
+ btn.innerHTML = '&#10003; Copied';
285
+ setTimeout(function() { btn.innerHTML = '&#128203; Copy'; }, 2000);
286
+ };
287
+ if (navigator.clipboard && navigator.clipboard.writeText) {
288
+ navigator.clipboard.writeText(text).then(done);
289
+ } else {
290
+ var ta = document.createElement('textarea');
291
+ ta.value = text;
292
+ ta.style.position = 'fixed';
293
+ ta.style.opacity = '0';
294
+ document.body.appendChild(ta);
295
+ ta.select();
296
+ document.execCommand('copy');
297
+ document.body.removeChild(ta);
298
+ done();
299
+ }
300
+ }
301
+
302
+ function ajax(method, url, data, onSuccess, onError) {
303
+ var xhr = new XMLHttpRequest();
304
+ xhr.open(method, url, true);
305
+ xhr.setRequestHeader('X-CSRF-Token', csrfToken);
306
+ xhr.setRequestHeader('Accept', 'application/json');
307
+ xhr.onload = function() {
308
+ var json;
309
+ try { json = JSON.parse(xhr.responseText); } catch(e) { json = null; }
310
+ if (xhr.status >= 200 && xhr.status < 300) {
311
+ onSuccess(json);
312
+ } else {
313
+ (onError || function(){})(json, xhr.status);
314
+ }
315
+ };
316
+ xhr.onerror = function() { (onError || function(){})(null, 0); };
317
+ if (data && method !== 'GET') {
318
+ xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
319
+ var parts = [];
320
+ for (var k in data) {
321
+ if (Array.isArray(data[k])) {
322
+ data[k].forEach(function(v, i) {
323
+ if (typeof v === 'object') {
324
+ for (var vk in v) parts.push(encodeURIComponent(k + '[' + i + '][' + vk + ']') + '=' + encodeURIComponent(v[vk]));
325
+ } else {
326
+ parts.push(encodeURIComponent(k + '[]') + '=' + encodeURIComponent(v));
327
+ }
328
+ });
329
+ } else {
330
+ parts.push(encodeURIComponent(k) + '=' + encodeURIComponent(data[k]));
331
+ }
332
+ }
333
+ xhr.send(parts.join('&'));
334
+ } else if (method === 'GET' && data) {
335
+ xhr.send();
336
+ } else {
337
+ xhr.send();
338
+ }
339
+ }
340
+
341
+ function ajaxGet(url, params, onSuccess, onError) {
342
+ var query = Object.keys(params).map(function(k) {
343
+ return encodeURIComponent(k) + '=' + encodeURIComponent(params[k]);
344
+ }).join('&');
345
+ ajax('GET', url + '?' + query, null, onSuccess, onError);
346
+ }
347
+
348
+ // --- Tabs ---
349
+
350
+ function activateTab(name) {
351
+ qsa('.mg-tab').forEach(function(t) { t.classList.remove('active'); });
352
+ qsa('.mg-tab-content').forEach(function(c) { c.classList.remove('active'); });
353
+ var btn = qs('[data-tab="' + name + '"]');
354
+ if (!btn) { name = 'dashboard'; btn = qs('[data-tab="dashboard"]'); }
355
+ btn.classList.add('active');
356
+ el('tab-' + name).classList.add('active');
357
+ history.replaceState(null, '', location.pathname + '#' + name);
358
+
359
+ // Hide query results and AI responses when leaving the explorer tab
360
+ if (name !== 'explorer') {
361
+ hide(el('query-results'));
362
+ hide(el('explain-results'));
363
+ hide(el('ai-query-result'));
364
+ }
365
+ if (name === 'dashboard') loadDashboard();
366
+ if (name === 'slow') loadSlowQueries();
367
+ if (name === 'indexes') loadDuplicateIndexes();
368
+ if (name === 'tables') loadTableSizes();
369
+ if (name === 'qstats') loadQueryStats();
370
+ if (name === 'unused') loadUnusedIndexes();
371
+ if (name === 'server') loadServerOverview();
372
+ }
373
+
374
+ qsa('.mg-tab').forEach(function(tab) {
375
+ tab.addEventListener('click', function() { activateTab(tab.dataset.tab); });
376
+ });
377
+
378
+ // --- Dashboard ---
379
+
380
+ function loadDashboard() {
381
+ show(el('dash-loading'));
382
+ hide(el('dash-content')); hide(el('dash-error'));
383
+ // Reset previous data
384
+ <% if capability?(:slow_queries) %>
385
+ hide(el('dash-slow-table')); hide(el('dash-slow-empty'));
386
+ el('dash-slow-tbody').innerHTML = '';
387
+ <% end %>
388
+ hide(el('dash-qstats-table')); hide(el('dash-qstats-empty')); hide(el('dash-qstats-error'));
389
+ el('dash-qstats-tbody').innerHTML = '';
390
+ el('dash-dup-count').textContent = '--';
391
+ el('dash-unused-count').textContent = '--';
392
+
393
+ var loaded = { server: false, slow: <%= capability?(:slow_queries) ? "false" : "true" %>, qstats: false, dup: false, unused: false };
394
+
395
+ function checkAllLoaded() {
396
+ if (!loaded.server || !loaded.slow || !loaded.qstats || !loaded.dup || !loaded.unused) return;
397
+ hide(el('dash-loading'));
398
+ show(el('dash-content'));
399
+ }
400
+
401
+ // Server overview
402
+ ajaxGet(ROUTES.server_overview, {}, function(data) {
403
+ if (data.error) { loaded.server = true; checkAllLoaded(); return; }
404
+ var s = data.server;
405
+ var c = data.connections;
406
+ var db = data.innodb;
407
+ var q = data.queries;
408
+
409
+ el('dash-server-info').innerHTML =
410
+ statRow('Version', '<code>' + escHtml(s.version) + '</code>') +
411
+ statRow('Uptime', escHtml(s.uptime)) +
412
+ statRow('Queries/sec', q.qps);
413
+
414
+ el('dash-conn-bar').innerHTML = usageBar(c.usage_pct, c.current + ' / ' + c.max + ' (' + c.usage_pct + '%)');
415
+ el('dash-conn-info').innerHTML =
416
+ statRow('Threads Running', c.threads_running) +
417
+ statRow('Max Used', c.max_used);
418
+
419
+ var poolUsedPct = db.buffer_pool_pages_total > 0
420
+ ? (((db.buffer_pool_pages_total - db.buffer_pool_pages_free) / db.buffer_pool_pages_total) * 100).toFixed(1)
421
+ : 0;
422
+ el('dash-innodb-bar').innerHTML = usageBar(parseFloat(poolUsedPct), db.buffer_pool_mb + ' MB (' + poolUsedPct + '% used)');
423
+ el('dash-innodb-info').innerHTML =
424
+ statRow('Hit Rate', db.buffer_pool_hit_rate + '%') +
425
+ statRow('Dirty Pages', Number(db.buffer_pool_pages_dirty).toLocaleString());
426
+
427
+ var tmpBadge = q.tmp_disk_pct > 25
428
+ ? '<span class="mg-badge mg-badge-danger">' + q.tmp_disk_pct + '%</span>'
429
+ : q.tmp_disk_pct + '%';
430
+ el('dash-query-info').innerHTML =
431
+ statRow('Slow Queries', Number(q.slow_queries).toLocaleString()) +
432
+ statRow('Tmp Disk Tables', tmpBadge);
433
+
434
+ loaded.server = true;
435
+ checkAllLoaded();
436
+ }, function() { loaded.server = true; checkAllLoaded(); });
437
+
438
+ <% if capability?(:slow_queries) %>
439
+ // Top 5 slow queries
440
+ ajaxGet(ROUTES.slow_queries, {}, function(data) {
441
+ if (!data || !data.length) {
442
+ el('dash-slow-empty').textContent = 'No slow queries recorded.';
443
+ show(el('dash-slow-empty'));
444
+ } else {
445
+ var top5 = data.slice(0, 5);
446
+ el('dash-slow-tbody').innerHTML = top5.map(function(q) {
447
+ var d = q.duration_ms;
448
+ var cls = d >= 2000 ? 'mg-badge-danger' : d >= 1000 ? 'mg-badge-warning' : 'mg-badge-info';
449
+ return '<tr><td><span class="mg-badge ' + cls + '">' + d + ' ms</span></td>' +
450
+ '<td><small>' + escHtml(q.timestamp) + '</small></td>' +
451
+ '<td>' + sqlBlock(q.sql, 120) + '</td></tr>';
452
+ }).join('');
453
+ show(el('dash-slow-table'));
454
+ }
455
+ loaded.slow = true;
456
+ checkAllLoaded();
457
+ }, function() {
458
+ el('dash-slow-empty').textContent = 'Configure redis_url to monitor slow queries.';
459
+ show(el('dash-slow-empty'));
460
+ loaded.slow = true;
461
+ checkAllLoaded();
462
+ });
463
+ <% end %>
464
+
465
+ // Top 5 expensive queries
466
+ ajaxGet(ROUTES.query_stats, { sort: 'total_time', limit: 5 }, function(data) {
467
+ if (data.error) {
468
+ el('dash-qstats-error').innerHTML = '<div class="mg-text-muted">' + escHtml(data.error) + '</div>';
469
+ show(el('dash-qstats-error'));
470
+ } else if (!data.length) {
471
+ el('dash-qstats-empty').textContent = 'No query statistics available.';
472
+ show(el('dash-qstats-empty'));
473
+ } else {
474
+ el('dash-qstats-tbody').innerHTML = data.map(function(q) {
475
+ var sqlCell = (q.digest && q.digest.length > 0)
476
+ ? '<a href="' + ROUTES.query_detail + encodeURIComponent(q.digest) + '" class="mg-link">' + sqlBlock(q.sql, 120) + '</a>'
477
+ : sqlBlock(q.sql, 120);
478
+ return '<tr>' +
479
+ '<td>' + sqlCell + '</td>' +
480
+ '<td class="mg-num">' + Number(q.calls).toLocaleString() + '</td>' +
481
+ '<td class="mg-num">' + formatDurationStyled(q.total_time_ms) + '</td>' +
482
+ '<td class="mg-num">' + formatDurationStyled(q.avg_time_ms) + '</td></tr>';
483
+ }).join('');
484
+ show(el('dash-qstats-table'));
485
+ }
486
+ loaded.qstats = true;
487
+ checkAllLoaded();
488
+ }, function() {
489
+ el('dash-qstats-empty').textContent = 'Query statistics unavailable.';
490
+ show(el('dash-qstats-empty'));
491
+ loaded.qstats = true;
492
+ checkAllLoaded();
493
+ });
494
+
495
+ // Duplicate indexes count
496
+ ajaxGet(ROUTES.duplicate_indexes, {}, function(data) {
497
+ var count = Array.isArray(data) ? data.length : 0;
498
+ var countEl = el('dash-dup-count');
499
+ if (count === 0) {
500
+ countEl.innerHTML = '<span style="color:#28a745;">&#10003; 0</span>';
501
+ } else {
502
+ countEl.innerHTML = '<span style="color:#dc3545;">' + count + '</span>';
503
+ }
504
+ loaded.dup = true;
505
+ checkAllLoaded();
506
+ }, function() { el('dash-dup-count').textContent = '?'; loaded.dup = true; checkAllLoaded(); });
507
+
508
+ // Unused indexes count
509
+ ajaxGet(ROUTES.unused_indexes, {}, function(data) {
510
+ var indexes = (data && data.indexes) || [];
511
+ var count = indexes.length;
512
+ var countEl = el('dash-unused-count');
513
+ if (count === 0) {
514
+ countEl.innerHTML = '<span style="color:#28a745;">&#10003; 0</span>';
515
+ } else {
516
+ countEl.innerHTML = '<span style="color:#dc3545;">' + count + '</span>';
517
+ }
518
+ loaded.unused = true;
519
+ checkAllLoaded();
520
+ }, function() { el('dash-unused-count').textContent = '?'; loaded.unused = true; checkAllLoaded(); });
521
+ }
522
+
523
+ // Dashboard tab-jump buttons
524
+ document.addEventListener('click', function(e) {
525
+ var jumpBtn = e.target.closest('.dash-jump-tab');
526
+ if (jumpBtn) { activateTab(jumpBtn.dataset.target); }
527
+ });
528
+
529
+ // Restore active tab from URL hash, or default to dashboard
530
+ var initialTab = (location.hash || '').replace('#', '') || 'dashboard';
531
+ activateTab(initialTab);
532
+
533
+ // --- Query Explorer Mode Toggle ---
534
+
535
+ qsa('.qe-mode').forEach(function(btn) {
536
+ btn.addEventListener('click', function() {
537
+ qsa('.qe-mode').forEach(function(b) {
538
+ b.classList.remove('active');
539
+ b.classList.add('mg-btn-outline');
540
+ });
541
+ btn.classList.add('active');
542
+ btn.classList.remove('mg-btn-outline');
543
+ if (btn.dataset.mode === 'visual') {
544
+ show(el('qe-visual'));
545
+ hide(el('qe-sql'));
546
+ } else {
547
+ hide(el('qe-visual'));
548
+ show(el('qe-sql'));
549
+ }
550
+ });
551
+ });
552
+
553
+ // --- Visual Builder ---
554
+
555
+ var typeLabels = { string: 'text', text: 'text', integer: 'number', boolean: 'yes/no', date: 'date', datetime: 'date/time', decimal: 'decimal', float: 'decimal' };
556
+ function colTypeLabel(type) { return typeLabels[type] || type; }
557
+
558
+ function operatorsForType(type) {
559
+ switch(type) {
560
+ case 'boolean': return ['=', '!=', 'IS NULL', 'IS NOT NULL'];
561
+ case 'date': case 'datetime': return ['=', '!=', '>', '<', '>=', '<=', 'BETWEEN', 'IS NULL', 'IS NOT NULL'];
562
+ case 'integer': case 'decimal': case 'float': return ['=', '!=', '>', '<', '>=', '<=', 'IS NULL', 'IS NOT NULL'];
563
+ default: return ['=', '!=', 'LIKE', '>', '<', 'IS NULL', 'IS NOT NULL'];
564
+ }
565
+ }
566
+
567
+ function loadColumnsForTable(table, callback) {
568
+ el('vb-columns').innerHTML = '';
569
+ el('vb-filters').innerHTML = '';
570
+ el('vb-orders').innerHTML = '';
571
+ columnTypeMap = {};
572
+ if (!table) {
573
+ hide(el('vb-columns-section')); hide(el('vb-filters-section'));
574
+ hide(el('vb-order-section')); hide(el('vb-generated-sql'));
575
+ el('vb-run').disabled = true; el('vb-explain').disabled = true;
576
+ return;
577
+ }
578
+ ajaxGet(ROUTES.columns, { table: table }, function(cols) {
579
+ currentColumns = cols;
580
+ cols.forEach(function(c) { columnTypeMap[c.name] = c.type; });
581
+ var html = '';
582
+ cols.forEach(function(col) {
583
+ var checked = col['default'] ? ' checked' : '';
584
+ html += '<label class="mg-check"><input type="checkbox" class="vb-col-check" value="' + escHtml(col.name) + '"' + checked +
585
+ ' data-default="' + (col['default'] ? '1' : '0') + '">' + escHtml(col.name) +
586
+ ' <span class="type-hint">(' + colTypeLabel(col.type) + ')</span></label>';
587
+ });
588
+ el('vb-columns').innerHTML = html;
589
+ show(el('vb-columns-section')); show(el('vb-filters-section'));
590
+ show(el('vb-order-section')); show(el('vb-generated-sql'));
591
+ el('vb-run').disabled = false; el('vb-explain').disabled = false;
592
+ if (callback) callback(cols); else updateGeneratedSql();
593
+ });
594
+ }
595
+
596
+ el('vb-table').addEventListener('change', function() { loadColumnsForTable(this.value); });
597
+
598
+ el('vb-toggle-all').addEventListener('click', function() {
599
+ var checks = qsa('.vb-col-check');
600
+ var allChecked = checks.every(function(c) { return c.checked; });
601
+ checks.forEach(function(c) { c.checked = !allChecked; });
602
+ updateGeneratedSql();
603
+ });
604
+
605
+ el('vb-show-defaults').addEventListener('click', function() {
606
+ qsa('.vb-col-check').forEach(function(c) { c.checked = c.dataset['default'] === '1'; });
607
+ updateGeneratedSql();
608
+ });
609
+
610
+ document.addEventListener('change', function(e) {
611
+ if (e.target.classList.contains('vb-col-check')) updateGeneratedSql();
612
+ });
613
+
614
+ function columnOptions(useAll) {
615
+ var cols = useAll ? currentColumns : currentColumns.filter(function(c) { return c['default']; });
616
+ var html = '<option value="">-- column --</option>';
617
+ cols.forEach(function(c) { html += '<option value="' + escHtml(c.name) + '">' + escHtml(c.name) + ' (' + colTypeLabel(c.type) + ')</option>'; });
618
+ return html;
619
+ }
620
+
621
+ function addFilterRow(useAll) {
622
+ var div = document.createElement('div');
623
+ div.className = 'mg-row mg-mb vb-filter-row';
624
+ div.style.alignItems = 'center';
625
+ div.innerHTML =
626
+ '<div class="mg-col-3"><select class="vb-filter-col">' + columnOptions(useAll) + '</select></div>' +
627
+ '<div class="mg-col-2"><select class="vb-filter-op"><option>=</option><option>!=</option><option>LIKE</option><option>></option><option><</option><option>IS NULL</option><option>IS NOT NULL</option></select></div>' +
628
+ '<div class="mg-col-4 vb-filter-val-container"><input type="text" class="vb-filter-val" placeholder="value"></div>' +
629
+ '<div><button class="mg-btn mg-btn-outline-danger mg-btn-sm vb-remove-filter">&#10005;</button></div>';
630
+ el('vb-filters').appendChild(div);
631
+ }
632
+
633
+ el('vb-add-filter').addEventListener('click', function() { addFilterRow(false); updateGeneratedSql(); });
634
+
635
+ document.addEventListener('change', function(e) {
636
+ if (e.target.classList.contains('vb-filter-col')) {
637
+ var row = e.target.closest('.vb-filter-row');
638
+ var colType = columnTypeMap[e.target.value] || 'string';
639
+ var opSel = qs('.vb-filter-op', row);
640
+ var ops = operatorsForType(colType);
641
+ opSel.innerHTML = ops.map(function(o) { return '<option>' + o + '</option>'; }).join('');
642
+ updateValueInput(row, colType, opSel.value);
643
+ updateGeneratedSql();
644
+ }
645
+ if (e.target.classList.contains('vb-filter-op')) {
646
+ var row = e.target.closest('.vb-filter-row');
647
+ var colName = qs('.vb-filter-col', row).value;
648
+ var colType = columnTypeMap[colName] || 'string';
649
+ updateValueInput(row, colType, e.target.value);
650
+ updateGeneratedSql();
651
+ }
652
+ });
653
+
654
+ document.addEventListener('input', function(e) {
655
+ if (e.target.classList.contains('vb-filter-val')) updateGeneratedSql();
656
+ });
657
+
658
+ document.addEventListener('click', function(e) {
659
+ if (e.target.closest('.vb-remove-filter')) { e.target.closest('.vb-filter-row').remove(); updateGeneratedSql(); }
660
+ if (e.target.closest('.vb-remove-order')) { e.target.closest('.vb-order-row').remove(); updateGeneratedSql(); }
661
+ });
662
+
663
+ function updateValueInput(row, colType, op) {
664
+ var container = qs('.vb-filter-val-container', row);
665
+ if (op === 'IS NULL' || op === 'IS NOT NULL') { container.innerHTML = ''; return; }
666
+ if (colType === 'boolean') {
667
+ container.innerHTML = '<select class="vb-filter-val"><option value="1">True</option><option value="0">False</option></select>';
668
+ } else if ((colType === 'date' || colType === 'datetime') && op === 'BETWEEN') {
669
+ container.innerHTML = '<input type="date" class="vb-filter-val" style="width:45%;display:inline-block;"> <span class="mg-text-muted">and</span> <input type="date" class="vb-filter-val-end" style="width:45%;display:inline-block;">';
670
+ } else if (colType === 'date' || colType === 'datetime') {
671
+ container.innerHTML = '<input type="date" class="vb-filter-val">';
672
+ } else if (colType === 'integer') {
673
+ container.innerHTML = '<input type="number" class="vb-filter-val" placeholder="number" step="1">';
674
+ } else if (colType === 'decimal' || colType === 'float') {
675
+ container.innerHTML = '<input type="number" class="vb-filter-val" placeholder="number" step="any">';
676
+ } else {
677
+ var ph = (op === 'LIKE') ? 'use % as wildcard' : 'value';
678
+ container.innerHTML = '<input type="text" class="vb-filter-val" placeholder="' + ph + '">';
679
+ }
680
+ }
681
+
682
+ // --- Order By ---
683
+
684
+ function addOrderRow(useAll) {
685
+ var cols = useAll ? currentColumns : currentColumns.filter(function(c) { return c['default']; });
686
+ var options = '<option value="">-- column --</option>';
687
+ cols.forEach(function(c) { options += '<option value="' + escHtml(c.name) + '">' + escHtml(c.name) + '</option>'; });
688
+ var div = document.createElement('div');
689
+ div.className = 'mg-row mg-mb vb-order-row';
690
+ div.style.alignItems = 'center';
691
+ div.innerHTML =
692
+ '<div class="mg-col-3"><select class="vb-order-col">' + options + '</select></div>' +
693
+ '<div class="mg-col-2"><select class="vb-order-dir"><option value="ASC">Ascending</option><option value="DESC">Descending</option></select></div>' +
694
+ '<div><button class="mg-btn mg-btn-outline-danger mg-btn-sm vb-remove-order">&#10005;</button></div>';
695
+ el('vb-orders').appendChild(div);
696
+ }
697
+
698
+ el('vb-add-order').addEventListener('click', function() { addOrderRow(false); updateGeneratedSql(); });
699
+
700
+ document.addEventListener('change', function(e) {
701
+ if (e.target.classList.contains('vb-order-col') || e.target.classList.contains('vb-order-dir')) updateGeneratedSql();
702
+ });
703
+
704
+ // --- Build SQL ---
705
+
706
+ function buildSql() {
707
+ var table = el('vb-table').value;
708
+ if (!table) return '';
709
+ var cols = [];
710
+ qsa('.vb-col-check:checked').forEach(function(c) { cols.push(quoteIdentifier(c.value)); });
711
+ if (!cols.length) cols = ['*'];
712
+ var sql = 'SELECT ' + cols.join(', ') + ' FROM ' + quoteIdentifier(table);
713
+ var wheres = [];
714
+ qsa('.vb-filter-row').forEach(function(row) {
715
+ var col = qs('.vb-filter-col', row).value;
716
+ var op = qs('.vb-filter-op', row).value;
717
+ var valEl = qs('.vb-filter-val', row);
718
+ var val = valEl ? valEl.value : '';
719
+ if (!col) return;
720
+ if (op === 'IS NULL' || op === 'IS NOT NULL') {
721
+ wheres.push(quoteIdentifier(col) + ' ' + op);
722
+ } else if (op === 'BETWEEN') {
723
+ var endEl = qs('.vb-filter-val-end', row);
724
+ var endVal = endEl ? endEl.value : '';
725
+ wheres.push(quoteIdentifier(col) + " BETWEEN '" + val.replace(/'/g, "''") + "' AND '" + endVal.replace(/'/g, "''") + "'");
726
+ } else if (op === 'LIKE') {
727
+ wheres.push(quoteIdentifier(col) + " LIKE '" + val.replace(/'/g, "''") + "'");
728
+ } else {
729
+ wheres.push(quoteIdentifier(col) + " " + op + " '" + val.replace(/'/g, "''") + "'");
730
+ }
731
+ });
732
+ if (wheres.length) sql += ' WHERE ' + wheres.join(' AND ');
733
+ var orders = [];
734
+ qsa('.vb-order-row').forEach(function(row) {
735
+ var col = qs('.vb-order-col', row).value;
736
+ var dir = qs('.vb-order-dir', row).value;
737
+ if (col) orders.push(quoteIdentifier(col) + ' ' + dir);
738
+ });
739
+ if (orders.length) sql += ' ORDER BY ' + orders.join(', ');
740
+ return sql;
741
+ }
742
+
743
+ function updateGeneratedSql() {
744
+ var sql = buildSql();
745
+ el('vb-sql-preview').value = sql;
746
+ el('sql-input').value = sql;
747
+ }
748
+
749
+ // --- Run Query ---
750
+
751
+ el('vb-run').addEventListener('click', function() {
752
+ var sql = buildSql();
753
+ if (sql) runQuery(sql, parseInt(el('vb-row-limit').value) || 25);
754
+ });
755
+
756
+ el('sql-run').addEventListener('click', function() {
757
+ var sql = el('sql-input').value.trim();
758
+ if (sql) runQuery(sql, parseInt(el('sql-row-limit').value) || 25);
759
+ });
760
+
761
+ el('sql-input').addEventListener('input', function() { el('vb-sql-preview').value = this.value; });
762
+
763
+ function runQuery(sql, rowLimit) {
764
+ clearResults();
765
+ setBtnLoading(['vb-run', 'sql-run'], true);
766
+ ajax('POST', ROUTES.execute, { sql: sql, row_limit: rowLimit }, function(data) {
767
+ renderResults(data);
768
+ setBtnLoading(['vb-run', 'sql-run'], false);
769
+ }, function(json) {
770
+ var cls = (json && json.timeout) ? 'mg-alert-warning' : 'mg-alert-danger';
771
+ el('results-alert').innerHTML = '<div class="mg-alert ' + cls + '">' + escHtml((json && json.error) || 'An unexpected error occurred.') + '</div>';
772
+ show(el('query-results'));
773
+ show(el('results-alert'));
774
+ setBtnLoading(['vb-run', 'sql-run'], false);
775
+ });
776
+ }
777
+
778
+ function clearResults() {
779
+ hide(el('results-alert')); hide(el('results-stats'));
780
+ hide(el('results-table-wrapper')); hide(el('results-empty'));
781
+ hide(el('results-truncated'));
782
+ el('results-thead').innerHTML = '';
783
+ el('results-tbody').innerHTML = '';
784
+ show(el('query-results'));
785
+ }
786
+
787
+ function renderResults(data) {
788
+ show(el('query-results'));
789
+ if (data.row_count === 0) {
790
+ show(el('results-empty')); show(el('results-stats'));
791
+ el('results-row-count').textContent = '0 rows';
792
+ el('results-time').textContent = data.execution_time_ms + ' ms';
793
+ return;
794
+ }
795
+ el('results-row-count').textContent = data.row_count + ' row' + (data.row_count !== 1 ? 's' : '');
796
+ el('results-time').textContent = data.execution_time_ms + ' ms';
797
+ if (data.truncated) show(el('results-truncated'));
798
+ show(el('results-stats'));
799
+
800
+ el('results-thead').innerHTML = '<tr>' + data.columns.map(function(c) { return '<th>' + escHtml(c) + '</th>'; }).join('') + '</tr>';
801
+ el('results-tbody').innerHTML = data.rows.map(function(row) {
802
+ return '<tr>' + row.map(function(val) {
803
+ if (val === null) return '<td><em class="null">NULL</em></td>';
804
+ if (val === '[REDACTED]') return '<td><span class="redacted">[REDACTED]</span></td>';
805
+ return '<td>' + escHtml(String(val)) + '</td>';
806
+ }).join('') + '</tr>';
807
+ }).join('');
808
+ show(el('results-table-wrapper'));
809
+ }
810
+
811
+ // --- Explain ---
812
+
813
+ el('vb-explain').addEventListener('click', function() { var sql = buildSql(); if (sql) runExplain(sql); });
814
+ el('sql-explain').addEventListener('click', function() { var sql = el('sql-input').value.trim(); if (sql) runExplain(sql); });
815
+ el('explain-close').addEventListener('click', function() { hide(el('explain-results')); });
816
+ el('results-close').addEventListener('click', function() { hide(el('query-results')); });
817
+
818
+ function runExplain(sql, fromSlowQuery) {
819
+ lastExplainSql = sql;
820
+ hide(el('explain-results')); hide(el('optimize-results'));
821
+ el('explain-thead').innerHTML = '';
822
+ el('explain-tbody').innerHTML = '';
823
+ setBtnLoading(['vb-explain', 'sql-explain'], true);
824
+
825
+ var postData = { sql: sql };
826
+ if (fromSlowQuery) postData.from_slow_query = 'true';
827
+ ajax('POST', ROUTES.explain, postData, function(data) {
828
+ lastExplainRows = data.rows;
829
+ el('explain-thead').innerHTML = '<tr>' + data.columns.map(function(c) { return '<th>' + escHtml(c) + '</th>'; }).join('') + '</tr>';
830
+ el('explain-tbody').innerHTML = data.rows.map(function(row) {
831
+ return '<tr>' + row.map(function(val) {
832
+ return val === null ? '<td><em class="null">NULL</em></td>' : '<td>' + escHtml(String(val)) + '</td>';
833
+ }).join('') + '</tr>';
834
+ }).join('');
835
+ show(el('explain-results'));
836
+ setBtnLoading(['vb-explain', 'sql-explain'], false);
837
+ }, function(json) {
838
+ el('results-alert').innerHTML = '<div class="mg-alert mg-alert-danger">' + escHtml((json && json.error) || 'Explain failed.') + '</div>';
839
+ show(el('results-alert'));
840
+ setBtnLoading(['vb-explain', 'sql-explain'], false);
841
+ });
842
+ }
843
+
844
+ // --- AI ---
845
+
846
+ var aiToggle = el('ai-toggle');
847
+ if (aiToggle) {
848
+ aiToggle.addEventListener('click', function() {
849
+ var panel = el('ai-panel');
850
+ panel.classList.toggle('mg-hidden');
851
+ });
852
+ }
853
+
854
+ var aiSuggest = el('ai-suggest');
855
+ if (aiSuggest) {
856
+ aiSuggest.addEventListener('click', function() {
857
+ var prompt = el('ai-prompt').value.trim();
858
+ if (!prompt) return;
859
+ aiSuggest.disabled = true;
860
+ aiSuggest.innerHTML = '<span class="mg-spinner"></span> Thinking...';
861
+ hide(el('ai-result'));
862
+
863
+ ajax('POST', ROUTES.suggest, { prompt: prompt }, function(data) {
864
+ el('sql-input').value = data.sql || '';
865
+ if (data.explanation) {
866
+ el('ai-explanation').textContent = data.explanation;
867
+ show(el('ai-result'));
868
+ }
869
+ parseSqlToBuilder(data.sql);
870
+ aiSuggest.disabled = false;
871
+ aiSuggest.innerHTML = '&#9889; Suggest Query';
872
+ }, function(json) {
873
+ el('results-alert').innerHTML = '<div class="mg-alert mg-alert-danger">' + escHtml((json && json.error) || 'AI suggestion failed.') + '</div>';
874
+ show(el('results-alert'));
875
+ aiSuggest.disabled = false;
876
+ aiSuggest.innerHTML = '&#9889; Suggest Query';
877
+ });
878
+ });
879
+ }
880
+
881
+ var explainOptimize = el('explain-optimize');
882
+ if (explainOptimize) {
883
+ explainOptimize.addEventListener('click', function() {
884
+ explainOptimize.disabled = true;
885
+ explainOptimize.innerHTML = '<span class="mg-spinner"></span> Analyzing...';
886
+ hide(el('optimize-results'));
887
+
888
+ var data = { sql: lastExplainSql };
889
+ lastExplainRows.forEach(function(row, i) {
890
+ row.forEach(function(val, j) {
891
+ data['explain_rows[' + i + '][' + j + ']'] = val == null ? '' : val;
892
+ });
893
+ });
894
+
895
+ ajax('POST', ROUTES.optimize, data, function(resp) {
896
+ el('optimize-content').innerHTML = formatMarkdown(resp.suggestions || 'No suggestions available.') + copyButton('optimize-content');
897
+ show(el('optimize-results'));
898
+ explainOptimize.disabled = false;
899
+ explainOptimize.innerHTML = '&#9889; AI Optimization';
900
+ }, function(json) {
901
+ el('optimize-content').textContent = (json && json.error) || 'Optimization failed.';
902
+ show(el('optimize-results'));
903
+ explainOptimize.disabled = false;
904
+ explainOptimize.innerHTML = '&#9889; AI Optimization';
905
+ });
906
+ });
907
+ }
908
+
909
+ // --- Slow Queries ---
910
+
911
+ function loadSlowQueries() {
912
+ show(el('slow-loading'));
913
+ hide(el('slow-empty')); hide(el('slow-table-wrapper'));
914
+ el('slow-tbody').innerHTML = '';
915
+
916
+ ajaxGet(ROUTES.slow_queries, {}, function(data) {
917
+ hide(el('slow-loading'));
918
+ if (!data || !data.length) { show(el('slow-empty')); el('slow-count').textContent = '0'; return; }
919
+ el('slow-count').textContent = data.length + ' queries';
920
+ el('slow-tbody').innerHTML = data.map(function(q) {
921
+ var d = q.duration_ms;
922
+ var cls = d >= 2000 ? 'mg-badge-danger' : d >= 1000 ? 'mg-badge-warning' : 'mg-badge-info';
923
+ return '<tr><td><span class="mg-badge ' + cls + '">' + d + ' ms</span></td>' +
924
+ '<td><small>' + escHtml(q.timestamp) + '</small></td>' +
925
+ '<td>' + sqlBlock(q.sql, 200) + '</td>' +
926
+ '<td><button class="mg-btn mg-btn-outline mg-btn-sm slow-explain-btn" data-sql="' + escHtml(q.sql).replace(/"/g, '&quot;') + '">Explain</button> ' +
927
+ '<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>';
928
+ }).join('');
929
+ show(el('slow-table-wrapper'));
930
+ makeSortable(qs('#slow-table-wrapper .mg-table'));
931
+ }, function() {
932
+ hide(el('slow-loading'));
933
+ el('slow-empty').textContent = 'Failed to load slow queries.';
934
+ show(el('slow-empty'));
935
+ });
936
+ }
937
+
938
+ <% if capability?(:slow_queries) %>
939
+ el('slow-refresh').addEventListener('click', loadSlowQueries);
940
+ <% end %>
941
+
942
+ document.addEventListener('click', function(e) {
943
+ var btn = e.target.closest('.slow-explain-btn');
944
+ if (btn) runExplain(btn.dataset.sql, true);
945
+ var useBtn = e.target.closest('.slow-use-btn');
946
+ if (useBtn) {
947
+ el('sql-input').value = useBtn.dataset.sql;
948
+ activateTab('explorer');
949
+ // Switch to SQL mode within Query Explorer
950
+ qsa('.qe-mode').forEach(function(b) { b.classList.remove('active'); b.classList.add('mg-btn-outline'); });
951
+ qs('.qe-mode[data-mode="sql"]').classList.add('active');
952
+ qs('.qe-mode[data-mode="sql"]').classList.remove('mg-btn-outline');
953
+ hide(el('qe-visual'));
954
+ show(el('qe-sql'));
955
+ }
956
+ });
957
+
958
+ // --- Duplicate Indexes ---
959
+
960
+ function loadDuplicateIndexes() {
961
+ show(el('dup-loading'));
962
+ hide(el('dup-empty')); hide(el('dup-table-wrapper'));
963
+ el('dup-tbody').innerHTML = '';
964
+
965
+ ajaxGet(ROUTES.duplicate_indexes, {}, function(data) {
966
+ hide(el('dup-loading'));
967
+ if (!data || !data.length) { show(el('dup-empty')); el('dup-count').textContent = '0'; return; }
968
+ el('dup-count').textContent = data.length + ' found';
969
+ el('dup-tbody').innerHTML = data.map(function(d) {
970
+ var dropSql = d.drop_sql || ('ALTER TABLE `' + d.table + '` DROP INDEX `' + d.duplicate_index + '`;');
971
+ return '<tr>' +
972
+ '<td><strong>' + escHtml(d.table) + '</strong></td>' +
973
+ '<td><code>' + escHtml(d.duplicate_index) + '</code>' + (d.unique ? ' <span class="mg-badge mg-badge-warning">UNIQUE</span>' : '') + '</td>' +
974
+ '<td>' + d.duplicate_columns.map(function(c) { return '<code>' + escHtml(c) + '</code>'; }).join(', ') + '</td>' +
975
+ '<td><code>' + escHtml(d.covered_by_index) + '</code></td>' +
976
+ '<td>' + d.covered_by_columns.map(function(c) { return '<code>' + escHtml(c) + '</code>'; }).join(', ') + '</td>' +
977
+ '<td>' + sqlBlock(dropSql, 0) + '</td>' +
978
+ '</tr>';
979
+ }).join('');
980
+ show(el('dup-table-wrapper'));
981
+ makeSortable(qs('#dup-table-wrapper .mg-table'));
982
+
983
+ // Generate migration
984
+ var ts = migrationTimestamp();
985
+ var migrationLines = ['# ' + ts + '_remove_duplicate_indexes.rb', '',
986
+ 'class RemoveDuplicateIndexes < ActiveRecord::Migration[' + RAILS_MIGRATION_VERSION + ']', ' def change'];
987
+ data.forEach(function(d) {
988
+ migrationLines.push(' remove_index :' + d.table + ', name: :' + d.duplicate_index);
989
+ });
990
+ migrationLines.push(' end', 'end');
991
+ el('dup-migration-code').textContent = migrationLines.join('\n');
992
+ show(el('dup-migration'));
993
+ }, function() {
994
+ hide(el('dup-loading'));
995
+ el('dup-empty').textContent = 'Failed to scan indexes.';
996
+ show(el('dup-empty'));
997
+ });
998
+ }
999
+
1000
+ el('dup-refresh').addEventListener('click', loadDuplicateIndexes);
1001
+ el('dup-copy-migration').addEventListener('click', function() {
1002
+ copyToClipboard(el('dup-migration-code').textContent, el('dup-copy-migration'));
1003
+ });
1004
+
1005
+ // --- Table Sizes ---
1006
+
1007
+ function formatMb(mb) {
1008
+ if (mb >= 1024) return (mb / 1024).toFixed(2) + ' GB';
1009
+ if (mb >= 1) return mb.toFixed(2) + ' MB';
1010
+ return (mb * 1024).toFixed(0) + ' KB';
1011
+ }
1012
+
1013
+ function sizeBar(pct, color) {
1014
+ return '<div style="background:#e9ecef;border-radius:3px;height:8px;width:100%;">' +
1015
+ '<div style="background:' + color + ';border-radius:3px;height:8px;width:' + Math.max(pct, 1) + '%;transition:width 0.3s;"></div></div>';
1016
+ }
1017
+
1018
+ var PIE_COLORS = [
1019
+ '#4e79a7', '#f28e2b', '#e15759', '#76b7b2', '#59a14f',
1020
+ '#edc948', '#b07aa1', '#ff9da7', '#9c755f', '#bab0ac',
1021
+ '#86bcb6', '#8cd17d', '#b6992d', '#499894', '#d37295'
1022
+ ];
1023
+
1024
+ function drawPieChart(canvas, slices, legendEl) {
1025
+ var ctx = canvas.getContext('2d');
1026
+ var w = canvas.width;
1027
+ var h = canvas.height;
1028
+ var cx = w / 2;
1029
+ var cy = h / 2;
1030
+ var r = Math.min(cx, cy) - 4;
1031
+ var total = slices.reduce(function(s, sl) { return s + sl.value; }, 0);
1032
+ if (total === 0) return;
1033
+
1034
+ ctx.clearRect(0, 0, w, h);
1035
+ var startAngle = -Math.PI / 2;
1036
+
1037
+ slices.forEach(function(sl, i) {
1038
+ var sliceAngle = (sl.value / total) * 2 * Math.PI;
1039
+ ctx.beginPath();
1040
+ ctx.moveTo(cx, cy);
1041
+ ctx.arc(cx, cy, r, startAngle, startAngle + sliceAngle);
1042
+ ctx.closePath();
1043
+ ctx.fillStyle = PIE_COLORS[i % PIE_COLORS.length];
1044
+ ctx.fill();
1045
+ ctx.strokeStyle = '#fff';
1046
+ ctx.lineWidth = 1.5;
1047
+ ctx.stroke();
1048
+ startAngle += sliceAngle;
1049
+ });
1050
+
1051
+ // Legend
1052
+ legendEl.innerHTML = slices.map(function(sl, i) {
1053
+ var pct = ((sl.value / total) * 100).toFixed(1);
1054
+ return '<div style="display:flex;align-items:center;gap:6px;">' +
1055
+ '<span style="display:inline-block;width:12px;height:12px;border-radius:2px;background:' + PIE_COLORS[i % PIE_COLORS.length] + ';flex-shrink:0;"></span>' +
1056
+ '<span>' + escHtml(sl.label) + '</span>' +
1057
+ '<span class="mg-text-muted">' + formatMb(sl.value) + ' (' + pct + '%)</span></div>';
1058
+ }).join('');
1059
+ }
1060
+
1061
+ function loadTableSizes() {
1062
+ show(el('sizes-loading'));
1063
+ hide(el('sizes-table-wrapper')); hide(el('sizes-chart-wrapper'));
1064
+ el('sizes-tbody').innerHTML = '';
1065
+
1066
+ ajaxGet(ROUTES.table_sizes, {}, function(data) {
1067
+ hide(el('sizes-loading'));
1068
+ if (!data || !data.length) { el('sizes-total').textContent = '0 tables'; show(el('sizes-table-wrapper')); return; }
1069
+
1070
+ var totalMb = data.reduce(function(sum, t) { return sum + t.total_mb; }, 0);
1071
+ el('sizes-total').textContent = data.length + ' tables, ' + formatMb(totalMb) + ' total';
1072
+ var maxMb = data[0].total_mb || 1;
1073
+
1074
+ // Pie chart: top 10 tables + "Other"
1075
+ var topN = data.slice(0, 10);
1076
+ var otherMb = data.slice(10).reduce(function(s, t) { return s + t.total_mb; }, 0);
1077
+ var slices = topN.map(function(t) { return { label: t.table, value: t.total_mb }; });
1078
+ if (otherMb > 0) slices.push({ label: 'Other (' + (data.length - 10) + ' tables)', value: otherMb });
1079
+ drawPieChart(el('sizes-pie'), slices, el('sizes-legend'));
1080
+ show(el('sizes-chart-wrapper'));
1081
+
1082
+ el('sizes-tbody').innerHTML = data.map(function(t) {
1083
+ var pct = (t.total_mb / maxMb) * 100;
1084
+ var color = t.total_mb >= 100 ? '#dc3545' : t.total_mb >= 10 ? '#ffc107' : '#28a745';
1085
+ var rows = t.rows != null ? Number(t.rows).toLocaleString() : '?';
1086
+ var fragText = t.fragmented_mb > 0 ? formatMb(t.fragmented_mb) : '<span class="mg-text-muted">\u2014</span>';
1087
+ if (t.needs_optimize) fragText += ' <span class="mg-badge mg-badge-warning" title="Fragmentation >10% of total size">optimize</span>';
1088
+ var updated = t.updated_at ? new Date(t.updated_at).toLocaleDateString() : '<span class="mg-text-muted">\u2014</span>';
1089
+ var engineBadge = t.engine ? '<span class="mg-badge mg-badge-secondary">' + escHtml(t.engine) + '</span>' : '';
1090
+ return '<tr>' +
1091
+ '<td><strong>' + escHtml(t.table) + '</strong>' +
1092
+ (t.collation ? ' <span class="mg-text-muted" style="font-size:11px">' + escHtml(t.collation) + '</span>' : '') +
1093
+ (t.auto_increment ? ' <span class="mg-text-muted" style="font-size:11px">AI:' + Number(t.auto_increment).toLocaleString() + '</span>' : '') +
1094
+ '</td>' +
1095
+ '<td class="mg-num">' + rows + '</td>' +
1096
+ '<td>' + engineBadge + '</td>' +
1097
+ '<td class="mg-num">' + formatMb(t.data_mb) + '</td>' +
1098
+ '<td class="mg-num">' + formatMb(t.index_mb) + '</td>' +
1099
+ '<td class="mg-num"><strong>' + formatMb(t.total_mb) + '</strong></td>' +
1100
+ '<td class="mg-num">' + fragText + '</td>' +
1101
+ '<td>' + updated + '</td>' +
1102
+ '<td>' + sizeBar(pct, color) +
1103
+ (t.needs_optimize ? ' <button class="mg-btn mg-btn-outline mg-btn-sm mg-table-optimize" data-table="' + escHtml(t.table) + '" style="margin-left:4px;">&#9889; AI Optimize</button>' : '') +
1104
+ '</td>' +
1105
+ '</tr>';
1106
+ }).join('');
1107
+ show(el('sizes-table-wrapper'));
1108
+ makeSortable(qs('#sizes-table-wrapper .mg-table'));
1109
+ }, function() {
1110
+ hide(el('sizes-loading'));
1111
+ el('sizes-total').textContent = 'Failed to load';
1112
+ });
1113
+ }
1114
+
1115
+ el('sizes-refresh').addEventListener('click', loadTableSizes);
1116
+
1117
+ // AI Optimize button on tables with fragmentation
1118
+ document.addEventListener('click', function(e) {
1119
+ var btn = e.target.closest('.mg-table-optimize');
1120
+ if (!btn) return;
1121
+ var table = btn.dataset.table;
1122
+ btn.disabled = true;
1123
+ btn.innerHTML = '<span class="mg-spinner"></span>';
1124
+ aiCall(ROUTES.schema_review, { table: table }, function(data) {
1125
+ showAiQueryResult('Optimization: ' + table, formatFindings(data.findings || data.raw || 'No suggestions.'));
1126
+ btn.disabled = false;
1127
+ btn.innerHTML = '&#9889; AI Optimize';
1128
+ }, function(err) {
1129
+ showAiQueryResult('Error', '<div class="mg-alert mg-alert-danger">' + escHtml(err) + '</div>');
1130
+ btn.disabled = false;
1131
+ btn.innerHTML = '&#9889; AI Optimize';
1132
+ });
1133
+ });
1134
+
1135
+ // --- Query Stats ---
1136
+
1137
+ function loadQueryStats() {
1138
+ show(el('qstats-loading'));
1139
+ hide(el('qstats-empty')); hide(el('qstats-table-wrapper')); hide(el('qstats-error'));
1140
+ el('qstats-tbody').innerHTML = '';
1141
+
1142
+ ajaxGet(ROUTES.query_stats, { sort: 'total_time' }, function(data) {
1143
+ hide(el('qstats-loading'));
1144
+ if (data.error) {
1145
+ el('qstats-error').innerHTML = '<div class="mg-alert mg-alert-warning">' + escHtml(data.error) + '</div>';
1146
+ show(el('qstats-error')); return;
1147
+ }
1148
+ if (!data.length) { show(el('qstats-empty')); el('qstats-count').textContent = '0'; return; }
1149
+ el('qstats-count').textContent = data.length + ' queries';
1150
+ el('qstats-tbody').innerHTML = data.map(function(q) {
1151
+ var ratioClass = q.rows_ratio > 100 ? 'mg-badge-danger' : q.rows_ratio > 10 ? 'mg-badge-warning' : '';
1152
+ var sqlCell = q.digest
1153
+ ? '<a href="' + ROUTES.query_detail + encodeURIComponent(q.digest) + '" class="mg-link">' + sqlBlock(q.sql, 120) + '</a>'
1154
+ : sqlBlock(q.sql, 120);
1155
+ return '<tr>' +
1156
+ '<td>' + sqlCell + '</td>' +
1157
+ '<td class="mg-num">' + Number(q.calls).toLocaleString() + '</td>' +
1158
+ '<td class="mg-num">' + formatDurationStyled(q.total_time_ms) + '</td>' +
1159
+ '<td class="mg-num">' + formatDurationStyled(q.avg_time_ms) + '</td>' +
1160
+ '<td class="mg-num">' + formatDurationStyled(q.max_time_ms) + '</td>' +
1161
+ '<td class="mg-num">' + Number(q.rows_examined).toLocaleString() + '</td>' +
1162
+ '<td class="mg-num">' + Number(q.rows_sent).toLocaleString() + '</td>' +
1163
+ '<td class="mg-num">' + (ratioClass ? '<span class="mg-badge ' + ratioClass + '">' + q.rows_ratio + 'x</span>' : q.rows_ratio + 'x') + '</td>' +
1164
+ '</tr>';
1165
+ }).join('');
1166
+ show(el('qstats-table-wrapper'));
1167
+ makeSortable(qs('#qstats-table-wrapper .mg-table'));
1168
+ }, function(json) {
1169
+ hide(el('qstats-loading'));
1170
+ el('qstats-error').innerHTML = '<div class="mg-alert mg-alert-warning">' + escHtml((json && json.error) || 'Failed to load query stats.') + '</div>';
1171
+ show(el('qstats-error'));
1172
+ });
1173
+ }
1174
+
1175
+ function formatDuration(ms) {
1176
+ if (ms >= 60000) return (ms / 60000).toFixed(1) + ' min';
1177
+ if (ms >= 1000) return (ms / 1000).toFixed(1) + ' s';
1178
+ return ms.toFixed(1) + ' ms';
1179
+ }
1180
+
1181
+ // --- Unused Indexes ---
1182
+
1183
+ function loadUnusedIndexes() {
1184
+ show(el('unused-loading'));
1185
+ hide(el('unused-empty')); hide(el('unused-table-wrapper')); hide(el('unused-error'));
1186
+ el('unused-tbody').innerHTML = '';
1187
+
1188
+ ajaxGet(ROUTES.unused_indexes, {}, function(data) {
1189
+ hide(el('unused-loading'));
1190
+ if (data.error) {
1191
+ el('unused-error').innerHTML = '<div class="mg-alert mg-alert-warning">' + escHtml(data.error) + '</div>';
1192
+ show(el('unused-error')); return;
1193
+ }
1194
+ var indexes = (data && data.indexes) || [];
1195
+ renderUnusedStatsContext(data);
1196
+ if (!indexes.length) { show(el('unused-empty')); el('unused-count').textContent = '0'; return; }
1197
+ el('unused-count').textContent = indexes.length + ' found';
1198
+ el('unused-tbody').innerHTML = indexes.map(function(d) {
1199
+ return '<tr>' +
1200
+ '<td><strong>' + escHtml(d.table) + '</strong></td>' +
1201
+ '<td><code>' + escHtml(d.index_name) + '</code></td>' +
1202
+ '<td class="mg-num">' + formatBytes(d.size_bytes) + '</td>' +
1203
+ '<td class="mg-num">' + d.reads + '</td>' +
1204
+ '<td class="mg-num">' + Number(d.writes).toLocaleString() + '</td>' +
1205
+ '<td class="mg-num">' + Number(d.table_rows).toLocaleString() + '</td>' +
1206
+ '<td>' + sqlBlock(d.drop_sql, 0) + '</td>' +
1207
+ '</tr>';
1208
+ }).join('');
1209
+ show(el('unused-table-wrapper'));
1210
+ makeSortable(qs('#unused-table-wrapper .mg-table'));
1211
+
1212
+ // Generate migration
1213
+ var ts = migrationTimestamp();
1214
+ var migrationLines = ['# ' + ts + '_remove_unused_indexes.rb', '',
1215
+ 'class RemoveUnusedIndexes < ActiveRecord::Migration[' + RAILS_MIGRATION_VERSION + ']', ' def change'];
1216
+ indexes.forEach(function(d) {
1217
+ migrationLines.push(' remove_index :' + d.table + ', name: :' + d.index_name);
1218
+ });
1219
+ migrationLines.push(' end', 'end');
1220
+ el('unused-migration-code').textContent = migrationLines.join('\n');
1221
+ show(el('unused-migration'));
1222
+ }, function(json) {
1223
+ hide(el('unused-loading'));
1224
+ el('unused-error').innerHTML = '<div class="mg-alert mg-alert-warning">' + escHtml((json && json.error) || 'Failed to load unused indexes.') + '</div>';
1225
+ show(el('unused-error'));
1226
+ });
1227
+ }
1228
+
1229
+ el('unused-refresh').addEventListener('click', loadUnusedIndexes);
1230
+ el('unused-copy-migration').addEventListener('click', function() {
1231
+ copyToClipboard(el('unused-migration-code').textContent, el('unused-copy-migration'));
1232
+ });
1233
+
1234
+ // --- Server Overview ---
1235
+
1236
+ function usageBar(pct, label) {
1237
+ var color = pct >= 90 ? '#dc3545' : pct >= 70 ? '#ffc107' : '#28a745';
1238
+ return '<div class="mg-usage-bar">' +
1239
+ '<div class="mg-usage-bar-fill" style="width:' + Math.min(pct, 100) + '%;background:' + color + ';"></div>' +
1240
+ '<div class="mg-usage-bar-text">' + label + '</div></div>';
1241
+ }
1242
+
1243
+ function statRow(label, value) {
1244
+ return '<div class="mg-stat-label">' + label + '</div><div class="mg-stat-value">' + value + '</div>';
1245
+ }
1246
+
1247
+ function loadServerOverview() {
1248
+ show(el('server-loading'));
1249
+ hide(el('server-content')); hide(el('server-error'));
1250
+
1251
+ ajaxGet(ROUTES.server_overview, {}, function(data) {
1252
+ hide(el('server-loading'));
1253
+ if (data.error) {
1254
+ el('server-error').innerHTML = '<div class="mg-alert mg-alert-warning">' + escHtml(data.error) + '</div>';
1255
+ show(el('server-error')); return;
1256
+ }
1257
+
1258
+ var s = data.server;
1259
+ var c = data.connections;
1260
+ var db = data.innodb;
1261
+ var q = data.queries;
1262
+
1263
+ // Server info
1264
+ el('server-info').innerHTML =
1265
+ statRow('Version', '<code>' + escHtml(s.version) + '</code>') +
1266
+ statRow('Uptime', escHtml(s.uptime)) +
1267
+ statRow('Queries/sec', q.qps) +
1268
+ statRow('Total Queries', Number(q.questions).toLocaleString()) +
1269
+ statRow('Slow Queries', Number(q.slow_queries).toLocaleString());
1270
+
1271
+ // Connections
1272
+ el('conn-bar').innerHTML = usageBar(c.usage_pct, c.current + ' / ' + c.max + ' (' + c.usage_pct + '%)');
1273
+ el('conn-info').innerHTML =
1274
+ statRow('Threads Running', c.threads_running) +
1275
+ statRow('Threads Cached', c.threads_cached) +
1276
+ statRow('Threads Created', Number(c.threads_created).toLocaleString()) +
1277
+ statRow('Max Used', c.max_used) +
1278
+ statRow('Aborted Connects', Number(c.aborted_connects).toLocaleString()) +
1279
+ statRow('Aborted Clients', Number(c.aborted_clients).toLocaleString());
1280
+
1281
+ // InnoDB
1282
+ var poolUsedPct = db.buffer_pool_pages_total > 0
1283
+ ? (((db.buffer_pool_pages_total - db.buffer_pool_pages_free) / db.buffer_pool_pages_total) * 100).toFixed(1)
1284
+ : 0;
1285
+ el('innodb-bar').innerHTML = usageBar(parseFloat(poolUsedPct), db.buffer_pool_mb + ' MB (' + poolUsedPct + '% used)');
1286
+ el('innodb-info').innerHTML =
1287
+ statRow('Hit Rate', db.buffer_pool_hit_rate + '%') +
1288
+ statRow('Dirty Pages', Number(db.buffer_pool_pages_dirty).toLocaleString()) +
1289
+ statRow('Free Pages', Number(db.buffer_pool_pages_free).toLocaleString()) +
1290
+ statRow('Row Lock Waits', Number(db.row_lock_waits).toLocaleString()) +
1291
+ statRow('Row Lock Time', formatDuration(db.row_lock_time_ms));
1292
+
1293
+ // Query activity
1294
+ var tmpBadge = q.tmp_disk_pct > 25
1295
+ ? '<span class="mg-badge mg-badge-danger">' + q.tmp_disk_pct + '%</span>'
1296
+ : q.tmp_disk_pct + '%';
1297
+ el('query-info').innerHTML =
1298
+ statRow('Tmp Tables (disk)', Number(q.tmp_disk_tables).toLocaleString() + ' / ' + Number(q.tmp_tables).toLocaleString() + ' ' + tmpBadge) +
1299
+ statRow('Full Joins (no index)', Number(q.select_full_join).toLocaleString()) +
1300
+ statRow('Sort Merge Passes', Number(q.sort_merge_passes).toLocaleString());
1301
+
1302
+ show(el('server-content'));
1303
+ }, function(json) {
1304
+ hide(el('server-loading'));
1305
+ el('server-error').innerHTML = '<div class="mg-alert mg-alert-warning">' + escHtml((json && json.error) || 'Failed to load server overview.') + '</div>';
1306
+ show(el('server-error'));
1307
+ });
1308
+ }
1309
+
1310
+ el('server-refresh').addEventListener('click', loadServerOverview);
1311
+
1312
+ // --- AI Feature Handlers ---
1313
+
1314
+ // Generic AI call helper
1315
+ function aiCall(url, data, onSuccess, onError) {
1316
+ ajax('POST', url, data, function(result) {
1317
+ if (result.error) { onError(result.error); return; }
1318
+ onSuccess(result);
1319
+ }, function(json) {
1320
+ onError((json && json.error) || 'AI request failed.');
1321
+ });
1322
+ }
1323
+
1324
+ function copyButton(targetId) {
1325
+ return '<div style="text-align:right;margin-top:8px;"><button class="mg-btn mg-btn-outline-secondary mg-btn-sm mg-copy-ai" data-target="' + targetId + '">Copy response</button></div>';
1326
+ }
1327
+
1328
+ document.addEventListener('click', function(e) {
1329
+ var btn = e.target.closest('.mg-copy-ai');
1330
+ if (!btn) return;
1331
+ var target = el(btn.dataset.target);
1332
+ if (!target) return;
1333
+ var text = target.innerText || target.textContent;
1334
+ navigator.clipboard.writeText(text).then(function() {
1335
+ btn.textContent = 'Copied!';
1336
+ setTimeout(function() { btn.textContent = 'Copy response'; }, 2000);
1337
+ });
1338
+ });
1339
+
1340
+ function showAiQueryResult(title, html) {
1341
+ el('ai-query-title').innerHTML = '<strong>&#9889; ' + escHtml(title) + '</strong>';
1342
+ el('ai-query-content').innerHTML = html + copyButton('ai-query-content');
1343
+ show(el('ai-query-result'));
1344
+ }
1345
+
1346
+ el('ai-query-close').addEventListener('click', function() { hide(el('ai-query-result')); });
1347
+
1348
+ // Describe Query
1349
+ var sqlDescribe = el('sql-describe');
1350
+ if (sqlDescribe) {
1351
+ sqlDescribe.addEventListener('click', function() {
1352
+ var sql = el('sql-input').value.trim();
1353
+ if (!sql) return;
1354
+ sqlDescribe.disabled = true;
1355
+ sqlDescribe.innerHTML = '<span class="mg-spinner"></span>';
1356
+ hide(el('ai-query-result'));
1357
+ aiCall(ROUTES.describe_query, { sql: sql }, function(data) {
1358
+ showAiQueryResult('Query Description', formatMarkdown(data.explanation || data.raw || 'No explanation returned.'));
1359
+ sqlDescribe.disabled = false;
1360
+ sqlDescribe.innerHTML = '&#9889; Describe';
1361
+ }, function(err) {
1362
+ showAiQueryResult('Error', '<div class="mg-alert mg-alert-danger">' + escHtml(err) + '</div>');
1363
+ sqlDescribe.disabled = false;
1364
+ sqlDescribe.innerHTML = '&#9889; Describe';
1365
+ });
1366
+ });
1367
+ }
1368
+
1369
+ // Rewrite Query
1370
+ var sqlRewrite = el('sql-rewrite');
1371
+ if (sqlRewrite) {
1372
+ sqlRewrite.addEventListener('click', function() {
1373
+ var sql = el('sql-input').value.trim();
1374
+ if (!sql) return;
1375
+ sqlRewrite.disabled = true;
1376
+ sqlRewrite.innerHTML = '<span class="mg-spinner"></span>';
1377
+ hide(el('ai-query-result'));
1378
+ aiCall(ROUTES.rewrite_query, { sql: sql }, function(data) {
1379
+ var html = '';
1380
+ if (data.rewritten) {
1381
+ html += '<strong>Rewritten Query:</strong><pre class="mg-pre"><code>' + escHtml(data.rewritten) + '</code></pre>';
1382
+ }
1383
+ if (data.changes) {
1384
+ html += '<strong>Changes:</strong><br>' + formatMarkdown(data.changes);
1385
+ }
1386
+ if (!data.rewritten && !data.changes) {
1387
+ html = formatMarkdown(data.raw || 'No rewrite suggestions.');
1388
+ }
1389
+ showAiQueryResult('Query Rewrite', html);
1390
+ sqlRewrite.disabled = false;
1391
+ sqlRewrite.innerHTML = '&#9889; Rewrite';
1392
+ }, function(err) {
1393
+ showAiQueryResult('Error', '<div class="mg-alert mg-alert-danger">' + escHtml(err) + '</div>');
1394
+ sqlRewrite.disabled = false;
1395
+ sqlRewrite.innerHTML = '&#9889; Rewrite';
1396
+ });
1397
+ });
1398
+ }
1399
+
1400
+ // Index Advisor (on EXPLAIN results)
1401
+ var indexAdvisor = el('explain-index-advisor');
1402
+ if (indexAdvisor) {
1403
+ indexAdvisor.addEventListener('click', function() {
1404
+ indexAdvisor.disabled = true;
1405
+ indexAdvisor.innerHTML = '<span class="mg-spinner"></span> Analyzing...';
1406
+ hide(el('optimize-results'));
1407
+
1408
+ var data = { sql: lastExplainSql };
1409
+ lastExplainRows.forEach(function(row, i) {
1410
+ row.forEach(function(val, j) {
1411
+ data['explain_rows[' + i + '][' + j + ']'] = val == null ? '' : val;
1412
+ });
1413
+ });
1414
+
1415
+ aiCall(ROUTES.index_advisor, data, function(resp) {
1416
+ el('optimize-content').innerHTML = formatMarkdown(resp.indexes || resp.raw || 'No suggestions.') + copyButton('optimize-content');
1417
+ show(el('optimize-results'));
1418
+ indexAdvisor.disabled = false;
1419
+ indexAdvisor.innerHTML = '&#9889; Index Advisor';
1420
+ }, function(err) {
1421
+ el('optimize-content').innerHTML = '<div class="mg-alert mg-alert-danger">' + escHtml(err) + '</div>';
1422
+ show(el('optimize-results'));
1423
+ indexAdvisor.disabled = false;
1424
+ indexAdvisor.innerHTML = '&#9889; Index Advisor';
1425
+ });
1426
+ });
1427
+ }
1428
+
1429
+ <% if capability?(:slow_queries) %>
1430
+ // Server: Root Cause Analysis
1431
+ var rootCauseBtn = el('server-root-cause');
1432
+ if (rootCauseBtn) {
1433
+ rootCauseBtn.addEventListener('click', function() {
1434
+ rootCauseBtn.disabled = true;
1435
+ rootCauseBtn.innerHTML = '<span class="mg-spinner"></span> Diagnosing...';
1436
+ hide(el('server-ai-result'));
1437
+ aiCall(ROUTES.root_cause, {}, function(data) {
1438
+ el('server-ai-title').innerHTML = '<strong>&#9889; Root Cause Analysis</strong>';
1439
+ el('server-ai-content').innerHTML = formatMarkdown(data.diagnosis || data.raw || 'No diagnosis.') + copyButton('server-ai-content');
1440
+ show(el('server-ai-result'));
1441
+ rootCauseBtn.disabled = false;
1442
+ rootCauseBtn.innerHTML = '&#9889; Why is it slow?';
1443
+ }, function(err) {
1444
+ el('server-ai-title').innerHTML = '<strong>&#9889; Error</strong>';
1445
+ el('server-ai-content').innerHTML = '<div class="mg-alert mg-alert-danger">' + escHtml(err) + '</div>';
1446
+ show(el('server-ai-result'));
1447
+ rootCauseBtn.disabled = false;
1448
+ rootCauseBtn.innerHTML = '&#9889; Why is it slow?';
1449
+ });
1450
+ });
1451
+ }
1452
+
1453
+ el('server-ai-close').addEventListener('click', function() { hide(el('server-ai-result')); });
1454
+
1455
+ // Server: Anomaly Detection
1456
+ var anomalyBtn = el('server-anomaly');
1457
+ if (anomalyBtn) {
1458
+ anomalyBtn.addEventListener('click', function() {
1459
+ anomalyBtn.disabled = true;
1460
+ anomalyBtn.innerHTML = '<span class="mg-spinner"></span> Analyzing...';
1461
+ hide(el('server-ai-result'));
1462
+ aiCall(ROUTES.anomaly_detection, {}, function(data) {
1463
+ el('server-ai-title').innerHTML = '<strong>&#9889; Query Health Report</strong>';
1464
+ el('server-ai-content').innerHTML = formatMarkdown(data.report || data.raw || 'No anomalies detected.') + copyButton('server-ai-content');
1465
+ show(el('server-ai-result'));
1466
+ anomalyBtn.disabled = false;
1467
+ anomalyBtn.innerHTML = '&#9889; Anomaly Detection';
1468
+ }, function(err) {
1469
+ el('server-ai-title').innerHTML = '<strong>&#9889; Error</strong>';
1470
+ el('server-ai-content').innerHTML = '<div class="mg-alert mg-alert-danger">' + escHtml(err) + '</div>';
1471
+ show(el('server-ai-result'));
1472
+ anomalyBtn.disabled = false;
1473
+ anomalyBtn.innerHTML = '&#9889; Anomaly Detection';
1474
+ });
1475
+ });
1476
+ }
1477
+ <% end %>
1478
+
1479
+ // Server: Variable Review
1480
+ var varReviewBtn = el('server-variable-review');
1481
+ if (varReviewBtn) {
1482
+ varReviewBtn.addEventListener('click', function() {
1483
+ varReviewBtn.disabled = true;
1484
+ varReviewBtn.innerHTML = '<span class="mg-spinner"></span> Reviewing...';
1485
+ hide(el('server-ai-result'));
1486
+ aiCall(ROUTES.variable_review, {}, function(data) {
1487
+ el('server-ai-title').innerHTML = '<strong>&#9889; Variable Configuration Review</strong>';
1488
+ el('server-ai-content').innerHTML = formatMarkdown(data.findings || data.raw || 'No findings.') + copyButton('server-ai-content');
1489
+ show(el('server-ai-result'));
1490
+ varReviewBtn.disabled = false;
1491
+ varReviewBtn.innerHTML = '&#9889; Variable Review';
1492
+ }, function(err) {
1493
+ el('server-ai-title').innerHTML = '<strong>&#9889; Error</strong>';
1494
+ el('server-ai-content').innerHTML = '<div class="mg-alert mg-alert-danger">' + escHtml(err) + '</div>';
1495
+ show(el('server-ai-result'));
1496
+ varReviewBtn.disabled = false;
1497
+ varReviewBtn.innerHTML = '&#9889; Variable Review';
1498
+ });
1499
+ });
1500
+ }
1501
+
1502
+ // Server: Connection Advisor
1503
+ var connAdvisorBtn = el('server-connection-advisor');
1504
+ if (connAdvisorBtn) {
1505
+ connAdvisorBtn.addEventListener('click', function() {
1506
+ connAdvisorBtn.disabled = true;
1507
+ connAdvisorBtn.innerHTML = '<span class="mg-spinner"></span> Diagnosing...';
1508
+ hide(el('server-ai-result'));
1509
+ aiCall(ROUTES.connection_advisor, {}, function(data) {
1510
+ el('server-ai-title').innerHTML = '<strong>&#9889; Connection Pressure Diagnosis</strong>';
1511
+ el('server-ai-content').innerHTML = formatMarkdown(data.diagnosis || data.raw || 'No diagnosis.') + copyButton('server-ai-content');
1512
+ show(el('server-ai-result'));
1513
+ connAdvisorBtn.disabled = false;
1514
+ connAdvisorBtn.innerHTML = '&#9889; Connection Advisor';
1515
+ }, function(err) {
1516
+ el('server-ai-title').innerHTML = '<strong>&#9889; Error</strong>';
1517
+ el('server-ai-content').innerHTML = '<div class="mg-alert mg-alert-danger">' + escHtml(err) + '</div>';
1518
+ show(el('server-ai-result'));
1519
+ connAdvisorBtn.disabled = false;
1520
+ connAdvisorBtn.innerHTML = '&#9889; Connection Advisor';
1521
+ });
1522
+ });
1523
+ }
1524
+
1525
+ // Server: InnoDB Health
1526
+ var innodbHealthBtn = el('server-innodb-health');
1527
+ if (innodbHealthBtn) {
1528
+ innodbHealthBtn.addEventListener('click', function() {
1529
+ innodbHealthBtn.disabled = true;
1530
+ innodbHealthBtn.innerHTML = '<span class="mg-spinner"></span> Interpreting...';
1531
+ hide(el('server-ai-result'));
1532
+ aiCall(ROUTES.innodb_health, {}, function(data) {
1533
+ el('server-ai-title').innerHTML = '<strong>&#9889; InnoDB Health Interpretation</strong>';
1534
+ el('server-ai-content').innerHTML = formatMarkdown(data.findings || data.raw || 'No findings.') + copyButton('server-ai-content');
1535
+ show(el('server-ai-result'));
1536
+ innodbHealthBtn.disabled = false;
1537
+ innodbHealthBtn.innerHTML = '&#9889; InnoDB Health';
1538
+ }, function(err) {
1539
+ el('server-ai-title').innerHTML = '<strong>&#9889; Error</strong>';
1540
+ el('server-ai-content').innerHTML = '<div class="mg-alert mg-alert-danger">' + escHtml(err) + '</div>';
1541
+ show(el('server-ai-result'));
1542
+ innodbHealthBtn.disabled = false;
1543
+ innodbHealthBtn.innerHTML = '&#9889; InnoDB Health';
1544
+ });
1545
+ });
1546
+ }
1547
+
1548
+ // Query Stats: Workload Digest
1549
+ var workloadBtn = el('qstats-workload-digest');
1550
+ if (workloadBtn) {
1551
+ workloadBtn.addEventListener('click', function() {
1552
+ workloadBtn.disabled = true;
1553
+ workloadBtn.innerHTML = '<span class="mg-spinner"></span> Analyzing...';
1554
+ hide(el('qstats-ai-result'));
1555
+ aiCall(ROUTES.workload_digest, {}, function(data) {
1556
+ el('qstats-ai-title').innerHTML = '<strong>&#9889; Workload Digest</strong>';
1557
+ el('qstats-ai-content').innerHTML = formatMarkdown(data.digest || data.raw || 'No digest available.') + copyButton('qstats-ai-content');
1558
+ show(el('qstats-ai-result'));
1559
+ workloadBtn.disabled = false;
1560
+ workloadBtn.innerHTML = '&#9889; Workload Digest';
1561
+ }, function(err) {
1562
+ el('qstats-ai-title').innerHTML = '<strong>&#9889; Error</strong>';
1563
+ el('qstats-ai-content').innerHTML = '<div class="mg-alert mg-alert-danger">' + escHtml(err) + '</div>';
1564
+ show(el('qstats-ai-result'));
1565
+ workloadBtn.disabled = false;
1566
+ workloadBtn.innerHTML = '&#9889; Workload Digest';
1567
+ });
1568
+ });
1569
+
1570
+ el('qstats-ai-close').addEventListener('click', function() { hide(el('qstats-ai-result')); });
1571
+ }
1572
+
1573
+ // Query Stats: Pattern Grouper
1574
+ var patternBtn = el('qstats-pattern-grouper');
1575
+ if (patternBtn) {
1576
+ patternBtn.addEventListener('click', function() {
1577
+ patternBtn.disabled = true;
1578
+ patternBtn.innerHTML = '<span class="mg-spinner"></span> Grouping...';
1579
+ hide(el('qstats-ai-result'));
1580
+ aiCall(ROUTES.pattern_grouper, {}, function(data) {
1581
+ el('qstats-ai-title').innerHTML = '<strong>&#9889; Slow Query Pattern Groups</strong>';
1582
+ el('qstats-ai-content').innerHTML = formatMarkdown(data.groups || data.raw || 'No patterns found.') + copyButton('qstats-ai-content');
1583
+ show(el('qstats-ai-result'));
1584
+ patternBtn.disabled = false;
1585
+ patternBtn.innerHTML = '&#9889; Pattern Grouper';
1586
+ }, function(err) {
1587
+ el('qstats-ai-title').innerHTML = '<strong>&#9889; Error</strong>';
1588
+ el('qstats-ai-content').innerHTML = '<div class="mg-alert mg-alert-danger">' + escHtml(err) + '</div>';
1589
+ show(el('qstats-ai-result'));
1590
+ patternBtn.disabled = false;
1591
+ patternBtn.innerHTML = '&#9889; Pattern Grouper';
1592
+ });
1593
+ });
1594
+ }
1595
+
1596
+ // Unused Indexes: Index Planner
1597
+ var indexPlannerBtn = el('unused-index-planner');
1598
+ if (indexPlannerBtn) {
1599
+ indexPlannerBtn.addEventListener('click', function() {
1600
+ indexPlannerBtn.disabled = true;
1601
+ indexPlannerBtn.innerHTML = '<span class="mg-spinner"></span> Planning...';
1602
+ hide(el('unused-ai-result'));
1603
+ aiCall(ROUTES.index_planner, {}, function(data) {
1604
+ el('unused-ai-title').innerHTML = '<strong>&#9889; Index Consolidation Plan</strong>';
1605
+ el('unused-ai-content').innerHTML = formatMarkdown(data.plan || data.raw || 'No plan generated.') + copyButton('unused-ai-content');
1606
+ show(el('unused-ai-result'));
1607
+ indexPlannerBtn.disabled = false;
1608
+ indexPlannerBtn.innerHTML = '&#9889; Index Planner';
1609
+ }, function(err) {
1610
+ el('unused-ai-title').innerHTML = '<strong>&#9889; Error</strong>';
1611
+ el('unused-ai-content').innerHTML = '<div class="mg-alert mg-alert-danger">' + escHtml(err) + '</div>';
1612
+ show(el('unused-ai-result'));
1613
+ indexPlannerBtn.disabled = false;
1614
+ indexPlannerBtn.innerHTML = '&#9889; Index Planner';
1615
+ });
1616
+ });
1617
+
1618
+ el('unused-ai-close').addEventListener('click', function() { hide(el('unused-ai-result')); });
1619
+ }
1620
+
1621
+ // Schema Review
1622
+ var schemaBtn = el('schema-review-btn');
1623
+ if (schemaBtn) {
1624
+ schemaBtn.addEventListener('click', function() {
1625
+ schemaBtn.disabled = true;
1626
+ schemaBtn.innerHTML = '<span class="mg-spinner"></span> Analyzing...';
1627
+ hide(el('schema-result'));
1628
+ var table = el('schema-table').value;
1629
+ aiCall(ROUTES.schema_review, { table: table }, function(data) {
1630
+ el('schema-result-content').innerHTML = formatFindings(data.findings || data.raw || '') + copyButton('schema-result-content');
1631
+ show(el('schema-result'));
1632
+ schemaBtn.disabled = false;
1633
+ schemaBtn.innerHTML = '&#9889; Analyze Schema';
1634
+ }, function(err) {
1635
+ el('schema-result-content').innerHTML = '<div class="mg-alert mg-alert-danger">' + escHtml(err) + '</div>';
1636
+ show(el('schema-result'));
1637
+ schemaBtn.disabled = false;
1638
+ schemaBtn.innerHTML = '&#9889; Analyze Schema';
1639
+ });
1640
+ });
1641
+ }
1642
+
1643
+ // Migration Risk
1644
+ var migrationBtn = el('migration-assess-btn');
1645
+ if (migrationBtn) {
1646
+ migrationBtn.addEventListener('click', function() {
1647
+ var migration = el('migration-input').value.trim();
1648
+ if (!migration) {
1649
+ el('migration-risk-badge').innerHTML = '';
1650
+ el('migration-result-content').innerHTML = '<div class="mg-alert mg-alert-warning">Please paste a Rails migration or DDL statement above.</div>';
1651
+ show(el('migration-result'));
1652
+ return;
1653
+ }
1654
+ migrationBtn.disabled = true;
1655
+ migrationBtn.innerHTML = '<span class="mg-spinner"></span> Assessing...';
1656
+ hide(el('migration-result'));
1657
+ aiCall(ROUTES.migration_risk, { migration: migration }, function(data) {
1658
+ var level = (data.risk_level || '').toLowerCase();
1659
+ var badgeClass = level === 'critical' ? 'mg-badge-danger' : level === 'high' ? 'mg-badge-danger' : level === 'medium' ? 'mg-badge-warning' : 'mg-badge-info';
1660
+ el('migration-risk-badge').innerHTML = level ? '<span class="mg-badge ' + badgeClass + '" style="font-size:14px;padding:4px 12px;">Risk: ' + level.toUpperCase() + '</span>' : '';
1661
+ el('migration-result-content').innerHTML = formatFindings(data.assessment || data.raw || '') + copyButton('migration-result-content');
1662
+ show(el('migration-result'));
1663
+ migrationBtn.disabled = false;
1664
+ migrationBtn.innerHTML = '&#9889; Assess Risk';
1665
+ }, function(err) {
1666
+ el('migration-risk-badge').innerHTML = '';
1667
+ el('migration-result-content').innerHTML = '<div class="mg-alert mg-alert-danger">' + escHtml(err) + '</div>';
1668
+ show(el('migration-result'));
1669
+ migrationBtn.disabled = false;
1670
+ migrationBtn.innerHTML = '&#9889; Assess Risk';
1671
+ });
1672
+ });
1673
+ }
1674
+
1675
+ // --- SQL to Builder sync ---
1676
+
1677
+ function parseSqlToBuilder(sql) {
1678
+ if (!sql) return;
1679
+ if (sql.match(/\b(JOIN|GROUP\s+BY|HAVING|UNION)\b/i) || (sql.match(/SELECT/gi) || []).length > 1) return;
1680
+ var tableMatch = sql.match(/FROM\s+`?(\w+)`?/i);
1681
+ if (!tableMatch) return;
1682
+ var tableName = tableMatch[1];
1683
+ if (!qs('#vb-table option[value="' + tableName + '"]')) return;
1684
+
1685
+ var colMatch = sql.match(/SELECT\s+(.*?)\s+FROM/i);
1686
+ var selectedCols = [];
1687
+ if (colMatch && colMatch[1].trim() !== '*') {
1688
+ colMatch[1].split(',').forEach(function(p) { var n = p.trim().replace(/`/g, ''); if (n) selectedCols.push(n); });
1689
+ }
1690
+
1691
+ var whereMatch = sql.match(/WHERE\s+(.*?)(?:\s+ORDER\s+BY\b|\s+LIMIT\b|\s*$)/i);
1692
+ var conditions = whereMatch ? parseWhereClause(whereMatch[1]) : [];
1693
+
1694
+ var orderMatch = sql.match(/ORDER\s+BY\s+(.*?)(?:\s+LIMIT\b|\s*$)/i);
1695
+ var orders = [];
1696
+ if (orderMatch) {
1697
+ orderMatch[1].split(',').forEach(function(p) {
1698
+ var m = p.trim().match(/`?(\w+)`?\s*(ASC|DESC)?/i);
1699
+ if (m) orders.push({ column: m[1], direction: (m[2] || 'ASC').toUpperCase() });
1700
+ });
1701
+ }
1702
+
1703
+ el('vb-table').value = tableName;
1704
+ loadColumnsForTable(tableName, function() {
1705
+ if (selectedCols.length) {
1706
+ qsa('.vb-col-check').forEach(function(c) { c.checked = selectedCols.indexOf(c.value) !== -1; });
1707
+ }
1708
+ el('vb-filters').innerHTML = '';
1709
+ conditions.forEach(function(cond) {
1710
+ addFilterRow(true);
1711
+ var row = el('vb-filters').lastElementChild;
1712
+ qs('.vb-filter-col', row).value = cond.column;
1713
+ var colType = columnTypeMap[cond.column] || 'string';
1714
+ var opSel = qs('.vb-filter-op', row);
1715
+ opSel.innerHTML = operatorsForType(colType).map(function(o) { return '<option>' + o + '</option>'; }).join('');
1716
+ opSel.value = cond.operator;
1717
+ updateValueInput(row, colType, cond.operator);
1718
+ if (cond.operator !== 'IS NULL' && cond.operator !== 'IS NOT NULL') {
1719
+ var valEl = qs('.vb-filter-val', row);
1720
+ if (valEl) valEl.value = cond.value;
1721
+ if (cond.operator === 'BETWEEN') { var endEl = qs('.vb-filter-val-end', row); if (endEl) endEl.value = cond.endValue; }
1722
+ }
1723
+ });
1724
+ el('vb-orders').innerHTML = '';
1725
+ orders.forEach(function(ord) {
1726
+ addOrderRow(true);
1727
+ var row = el('vb-orders').lastElementChild;
1728
+ qs('.vb-order-col', row).value = ord.column;
1729
+ qs('.vb-order-dir', row).value = ord.direction;
1730
+ });
1731
+ updateGeneratedSql();
1732
+ });
1733
+ }
1734
+
1735
+ function parseWhereClause(str) {
1736
+ var conditions = [];
1737
+ var betweenRe = /`?(\w+)`?\s+BETWEEN\s+'([^']*)'\s+AND\s+'([^']*)'/gi;
1738
+ var m;
1739
+ while ((m = betweenRe.exec(str)) !== null) conditions.push({ column: m[1], operator: 'BETWEEN', value: m[2], endValue: m[3] });
1740
+ var remaining = str.replace(betweenRe, '{{B}}');
1741
+ remaining.split(/\s+AND\s+/i).forEach(function(part) {
1742
+ part = part.trim();
1743
+ if (!part || part === '{{B}}') return;
1744
+ var m;
1745
+ if ((m = part.match(/`?(\w+)`?\s+IS\s+NOT\s+NULL/i))) { conditions.push({ column: m[1], operator: 'IS NOT NULL', value: '' }); return; }
1746
+ if ((m = part.match(/`?(\w+)`?\s+IS\s+NULL/i))) { conditions.push({ column: m[1], operator: 'IS NULL', value: '' }); return; }
1747
+ if ((m = part.match(/`?(\w+)`?\s+LIKE\s+'([^']*)'/i))) { conditions.push({ column: m[1], operator: 'LIKE', value: m[2] }); return; }
1748
+ if ((m = part.match(/`?(\w+)`?\s*(!=|>=|<=|=|>|<)\s*'([^']*)'/))) { conditions.push({ column: m[1], operator: m[2], value: m[3] }); return; }
1749
+ if ((m = part.match(/`?(\w+)`?\s*(!=|>=|<=|=|>|<)\s*(\d+\.?\d*)/))) { conditions.push({ column: m[1], operator: m[2], value: m[3] }); return; }
1750
+ });
1751
+ return conditions;
1752
+ }
1753
+
1754
+ // --- Utilities ---
1755
+
1756
+ function setBtnLoading(ids, loading) {
1757
+ ids.forEach(function(id) {
1758
+ var btn = el(id);
1759
+ if (!btn) return;
1760
+ btn.disabled = loading;
1761
+ if (loading) { btn.dataset.origHtml = btn.innerHTML; btn.innerHTML = '<span class="mg-spinner"></span>'; }
1762
+ else if (btn.dataset.origHtml) { btn.innerHTML = btn.dataset.origHtml; }
1763
+ });
1764
+ }
1765
+
1766
+ function formatMarkdown(text) {
1767
+ if (!text) return '';
1768
+ // Normalize literal \n from JSON strings to actual newlines
1769
+ text = text.replace(/\\n/g, '\n');
1770
+ return text
1771
+ .replace(/```sql\n?([\s\S]*?)```/g, '<pre class="mg-pre"><code>$1</code></pre>')
1772
+ .replace(/```\n?([\s\S]*?)```/g, '<pre class="mg-pre"><code>$1</code></pre>')
1773
+ .replace(/`([^`]+)`/g, '<code>$1</code>')
1774
+ .replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
1775
+ .replace(/^####\s+(.+)$/gm, '<h5 style="margin:12px 0 4px;">$1</h5>')
1776
+ .replace(/^###\s+(.+)$/gm, '<h4 style="margin:16px 0 6px;">$1</h4>')
1777
+ .replace(/^##\s+(.+)$/gm, '<h3 style="margin:20px 0 8px;">$1</h3>')
1778
+ .replace(/^#\s+(.+)$/gm, '<h3 style="margin:20px 0 8px;">$1</h3>')
1779
+ .replace(/^---+$/gm, '<hr style="margin:12px 0;">')
1780
+ .replace(/^\d+\.\s+(.+)$/gm, '<li>$1</li>')
1781
+ .replace(/^[-*]\s+(.+)$/gm, '<li>$1</li>')
1782
+ .replace(/(<li>[\s\S]*?<\/li>)/g, '<ul style="margin:4px 0 4px 16px;padding:0;">$1</ul>')
1783
+ .replace(/<\/ul>\s*<ul[^>]*>/g, '')
1784
+ .replace(/\n\n/g, '<br><br>')
1785
+ .replace(/\n/g, '<br>');
1786
+ }
1787
+
1788
+ function formatFindings(text) {
1789
+ if (!text) return '<div class="mg-text-muted">No findings.</div>';
1790
+ text = text.replace(/\\n/g, '\n');
1791
+
1792
+ // Split into severity sections
1793
+ var sections = [];
1794
+ var current = null;
1795
+ text.split('\n').forEach(function(line) {
1796
+ var heading = line.match(/^#{1,3}\s+(.+)/);
1797
+ if (heading) {
1798
+ var title = heading[1].replace(/\*\*/g, '');
1799
+ var severity = 'info';
1800
+ var titleLower = title.toLowerCase();
1801
+ if (titleLower.indexOf('critical') !== -1) severity = 'danger';
1802
+ else if (titleLower.indexOf('warning') !== -1) severity = 'warning';
1803
+ else if (titleLower.indexOf('suggestion') !== -1 || titleLower.indexOf('info') !== -1) severity = 'info';
1804
+ current = { title: title, severity: severity, lines: [] };
1805
+ sections.push(current);
1806
+ } else if (current) {
1807
+ current.lines.push(line);
1808
+ } else {
1809
+ if (!sections.length) sections.push({ title: '', severity: 'info', lines: [] });
1810
+ if (!current) current = sections[0];
1811
+ current.lines.push(line);
1812
+ }
1813
+ });
1814
+
1815
+ if (!sections.length) return formatMarkdown(text);
1816
+
1817
+ return sections.map(function(sec) {
1818
+ var content = formatMarkdown(sec.lines.join('\n').trim());
1819
+ if (!content || content === '<br>') return '';
1820
+ return '<div class="mg-ai-section mg-ai-' + sec.severity + ' mg-mb">' +
1821
+ (sec.title ? '<div class="mg-ai-section-header"><strong>' + escHtml(sec.title) + '</strong></div>' : '') +
1822
+ '<div class="mg-ai-section-body">' + content + '</div></div>';
1823
+ }).filter(function(s) { return s; }).join('');
1824
+ }
1825
+ })();
1826
+ </script>