mysql_genius 0.1.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 (47) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/ci.yml +53 -0
  3. data/.gitignore +11 -0
  4. data/.rspec +3 -0
  5. data/CHANGELOG.md +13 -0
  6. data/Gemfile +15 -0
  7. data/LICENSE.txt +21 -0
  8. data/README.md +295 -0
  9. data/Rakefile +6 -0
  10. data/app/controllers/concerns/mysql_genius/ai_features.rb +360 -0
  11. data/app/controllers/concerns/mysql_genius/database_analysis.rb +259 -0
  12. data/app/controllers/concerns/mysql_genius/query_execution.rb +129 -0
  13. data/app/controllers/mysql_genius/base_controller.rb +18 -0
  14. data/app/controllers/mysql_genius/queries_controller.rb +54 -0
  15. data/app/services/mysql_genius/ai_client.rb +84 -0
  16. data/app/services/mysql_genius/ai_optimization_service.rb +56 -0
  17. data/app/services/mysql_genius/ai_suggestion_service.rb +56 -0
  18. data/app/views/layouts/mysql_genius/application.html.erb +116 -0
  19. data/app/views/mysql_genius/queries/_shared_results.html.erb +56 -0
  20. data/app/views/mysql_genius/queries/_tab_ai_tools.html.erb +43 -0
  21. data/app/views/mysql_genius/queries/_tab_duplicate_indexes.html.erb +24 -0
  22. data/app/views/mysql_genius/queries/_tab_query_stats.html.erb +36 -0
  23. data/app/views/mysql_genius/queries/_tab_server.html.erb +54 -0
  24. data/app/views/mysql_genius/queries/_tab_slow_queries.html.erb +17 -0
  25. data/app/views/mysql_genius/queries/_tab_sql_query.html.erb +40 -0
  26. data/app/views/mysql_genius/queries/_tab_table_sizes.html.erb +31 -0
  27. data/app/views/mysql_genius/queries/_tab_unused_indexes.html.erb +25 -0
  28. data/app/views/mysql_genius/queries/_tab_visual_builder.html.erb +61 -0
  29. data/app/views/mysql_genius/queries/index.html.erb +1185 -0
  30. data/bin/console +14 -0
  31. data/bin/setup +8 -0
  32. data/config/routes.rb +24 -0
  33. data/docs/screenshots/ai_tools.png +0 -0
  34. data/docs/screenshots/duplicate_indexes.png +0 -0
  35. data/docs/screenshots/query_stats.png +0 -0
  36. data/docs/screenshots/server.png +0 -0
  37. data/docs/screenshots/sql_query.png +0 -0
  38. data/docs/screenshots/table_sizes.png +0 -0
  39. data/docs/screenshots/visual_builder.png +0 -0
  40. data/lib/mysql_genius/configuration.rb +96 -0
  41. data/lib/mysql_genius/engine.rb +12 -0
  42. data/lib/mysql_genius/slow_query_monitor.rb +38 -0
  43. data/lib/mysql_genius/sql_validator.rb +55 -0
  44. data/lib/mysql_genius/version.rb +3 -0
  45. data/lib/mysql_genius.rb +23 -0
  46. data/mysql_genius.gemspec +34 -0
  47. metadata +122 -0
@@ -0,0 +1,129 @@
1
+ module MysqlGenius
2
+ module QueryExecution
3
+ extend ActiveSupport::Concern
4
+
5
+ def execute
6
+ sql = params[:sql].to_s.strip
7
+ row_limit = if params[:row_limit].present?
8
+ [[params[:row_limit].to_i, 1].max, mysql_genius_config.max_row_limit].min
9
+ else
10
+ mysql_genius_config.default_row_limit
11
+ end
12
+
13
+ error = validate_sql(sql)
14
+ if error
15
+ audit(:rejection, sql: sql, reason: error)
16
+ return render json: { error: error }, status: :unprocessable_entity
17
+ end
18
+
19
+ limited_sql = apply_row_limit(sql, row_limit)
20
+ timed_sql = apply_timeout_hint(limited_sql)
21
+
22
+ start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
23
+ begin
24
+ results = ActiveRecord::Base.connection.exec_query(timed_sql)
25
+ execution_time_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000).round(1)
26
+
27
+ columns = results.columns
28
+ rows = results.rows.map do |row|
29
+ row.each_with_index.map do |value, i|
30
+ masked_column?(columns[i]) ? "[REDACTED]" : value
31
+ end
32
+ end
33
+
34
+ truncated = rows.length >= row_limit
35
+
36
+ audit(:query, sql: sql, execution_time_ms: execution_time_ms, row_count: rows.length)
37
+
38
+ render json: {
39
+ columns: columns,
40
+ rows: rows,
41
+ row_count: rows.length,
42
+ execution_time_ms: execution_time_ms,
43
+ truncated: truncated
44
+ }
45
+ rescue ActiveRecord::StatementInvalid => e
46
+ if timeout_error?(e)
47
+ audit(:error, sql: sql, error: "Query timeout")
48
+ render json: { error: "Query exceeded the #{mysql_genius_config.query_timeout_ms / 1000} second timeout limit.", timeout: true }, status: :unprocessable_entity
49
+ else
50
+ audit(:error, sql: sql, error: e.message)
51
+ render json: { error: "Query error: #{e.message.split(':').last.strip}" }, status: :unprocessable_entity
52
+ end
53
+ end
54
+ end
55
+
56
+ def explain
57
+ sql = params[:sql].to_s.strip
58
+ skip_validation = params[:from_slow_query] == "true"
59
+
60
+ unless skip_validation
61
+ error = validate_sql(sql)
62
+ return render json: { error: error }, status: :unprocessable_entity if error
63
+ end
64
+
65
+ # Reject truncated SQL (captured slow queries are capped at 2000 chars)
66
+ unless sql.match?(/\)\s*$/) || sql.match?(/\w\s*$/) || sql.match?(/['"`]\s*$/) || sql.match?(/\d\s*$/)
67
+ return render json: { error: "This query appears to be truncated and cannot be explained." }, status: :unprocessable_entity
68
+ end
69
+
70
+ explain_sql = "EXPLAIN #{sql.gsub(/;\s*\z/, '')}"
71
+ results = ActiveRecord::Base.connection.exec_query(explain_sql)
72
+
73
+ render json: { columns: results.columns, rows: results.rows }
74
+ rescue ActiveRecord::StatementInvalid => e
75
+ render json: { error: "Explain error: #{e.message.split(':').last.strip}" }, status: :unprocessable_entity
76
+ end
77
+
78
+ private
79
+
80
+ def validate_sql(sql)
81
+ SqlValidator.validate(sql, blocked_tables: mysql_genius_config.blocked_tables, connection: ActiveRecord::Base.connection)
82
+ end
83
+
84
+ def apply_timeout_hint(sql)
85
+ if mariadb?
86
+ timeout_seconds = mysql_genius_config.query_timeout_ms / 1000
87
+ "SET STATEMENT max_statement_time=#{timeout_seconds} FOR #{sql}"
88
+ else
89
+ sql.sub(/\bSELECT\b/i, "SELECT /*+ MAX_EXECUTION_TIME(#{mysql_genius_config.query_timeout_ms}) */")
90
+ end
91
+ end
92
+
93
+ def mariadb?
94
+ @mariadb ||= ActiveRecord::Base.connection.select_value("SELECT VERSION()").to_s.include?("MariaDB")
95
+ end
96
+
97
+ def apply_row_limit(sql, limit)
98
+ SqlValidator.apply_row_limit(sql, limit)
99
+ end
100
+
101
+ def timeout_error?(exception)
102
+ msg = exception.message
103
+ msg.include?("max_statement_time") || msg.include?("max_execution_time") || msg.include?("Query execution was interrupted")
104
+ end
105
+
106
+ def masked_column?(column_name)
107
+ SqlValidator.masked_column?(column_name, mysql_genius_config.masked_column_patterns)
108
+ end
109
+
110
+ def sanitize_ai_sql(sql)
111
+ sql.gsub(/```(?:sql)?\s*/i, "").gsub(/```/, "").strip
112
+ end
113
+
114
+ def audit(type, **attrs)
115
+ logger = mysql_genius_config.audit_logger
116
+ return unless logger
117
+
118
+ prefix = "[#{Time.current.iso8601}] [mysql_genius]"
119
+ case type
120
+ when :query
121
+ logger.info("#{prefix} rows=#{attrs[:row_count]} time=#{attrs[:execution_time_ms]}ms sql=#{attrs[:sql].squish}")
122
+ when :rejection
123
+ logger.warn("#{prefix} REJECTED reason=#{attrs[:reason]} sql=#{attrs[:sql].to_s.squish}")
124
+ when :error
125
+ logger.error("#{prefix} ERROR error=#{attrs[:error]} sql=#{attrs[:sql].to_s.squish}")
126
+ end
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,18 @@
1
+ module MysqlGenius
2
+ class BaseController < MysqlGenius.configuration.base_controller.constantize
3
+ layout "mysql_genius/application"
4
+ before_action :authenticate_mysql_genius!
5
+
6
+ private
7
+
8
+ def authenticate_mysql_genius!
9
+ unless MysqlGenius.configuration.authenticate.call(self)
10
+ render plain: "Not authorized", status: :unauthorized
11
+ end
12
+ end
13
+
14
+ def mysql_genius_config
15
+ MysqlGenius.configuration
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,54 @@
1
+ module MysqlGenius
2
+ class QueriesController < BaseController
3
+ include QueryExecution
4
+ include DatabaseAnalysis
5
+ include AiFeatures
6
+
7
+ def index
8
+ @featured_tables = if mysql_genius_config.featured_tables.any?
9
+ mysql_genius_config.featured_tables.sort
10
+ else
11
+ queryable_tables.sort
12
+ end
13
+ @all_tables = queryable_tables.sort
14
+ @ai_enabled = mysql_genius_config.ai_enabled?
15
+ end
16
+
17
+ def columns
18
+ table = params[:table]
19
+ if mysql_genius_config.blocked_tables.include?(table)
20
+ return render json: { error: "Table '#{table}' is not available for querying." }, status: :forbidden
21
+ end
22
+
23
+ unless ActiveRecord::Base.connection.tables.include?(table)
24
+ return render json: { error: "Table '#{table}' does not exist." }, status: :not_found
25
+ end
26
+
27
+ defaults = mysql_genius_config.default_columns[table] || []
28
+ cols = ActiveRecord::Base.connection.columns(table).reject { |c| masked_column?(c.name) }.map do |c|
29
+ { name: c.name, type: c.type.to_s, default: defaults.empty? || defaults.include?(c.name) }
30
+ end
31
+ render json: cols
32
+ end
33
+
34
+ def slow_queries
35
+ unless mysql_genius_config.redis_url.present?
36
+ return render json: { error: "Slow query monitoring is not configured." }, status: :not_found
37
+ end
38
+
39
+ redis = Redis.new(url: mysql_genius_config.redis_url)
40
+ key = SlowQueryMonitor.redis_key
41
+ raw = redis.lrange(key, 0, 199)
42
+ queries = raw.map { |entry| JSON.parse(entry) rescue nil }.compact
43
+ render json: queries
44
+ rescue => e
45
+ render json: [], status: :ok
46
+ end
47
+
48
+ private
49
+
50
+ def queryable_tables
51
+ ActiveRecord::Base.connection.tables - mysql_genius_config.blocked_tables
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,84 @@
1
+ require "net/http"
2
+ require "json"
3
+ require "uri"
4
+
5
+ module MysqlGenius
6
+ class AiClient
7
+ MAX_REDIRECTS = 3
8
+
9
+ def initialize
10
+ @config = MysqlGenius.configuration
11
+ end
12
+
13
+ def chat(messages:, temperature: 0)
14
+ if @config.ai_client
15
+ return @config.ai_client.call(messages: messages, temperature: temperature)
16
+ end
17
+
18
+ raise Error, "AI is not configured" unless @config.ai_endpoint && @config.ai_api_key
19
+
20
+ body = {
21
+ messages: messages,
22
+ response_format: { type: "json_object" },
23
+ temperature: temperature
24
+ }
25
+ body[:model] = @config.ai_model if @config.ai_model.present?
26
+
27
+ response = post_with_redirects(URI(@config.ai_endpoint), body.to_json)
28
+ parsed = JSON.parse(response.body)
29
+
30
+ if parsed["error"]
31
+ raise Error, "AI API error: #{parsed['error']['message'] || parsed['error']}"
32
+ end
33
+
34
+ content = parsed.dig("choices", 0, "message", "content")
35
+ raise Error, "No content in AI response" if content.nil?
36
+
37
+ parse_json_content(content)
38
+ end
39
+
40
+ private
41
+
42
+ def parse_json_content(content)
43
+ # Try direct parse first
44
+ JSON.parse(content)
45
+ rescue JSON::ParserError
46
+ # Strip markdown code fences that some models wrap around JSON
47
+ stripped = content.to_s
48
+ .gsub(/\A\s*```(?:json)?\s*/i, "")
49
+ .gsub(/\s*```\s*\z/, "")
50
+ .strip
51
+ begin
52
+ JSON.parse(stripped)
53
+ rescue JSON::ParserError
54
+ { "raw" => content.to_s }
55
+ end
56
+ end
57
+
58
+ def post_with_redirects(uri, body, redirects = 0)
59
+ raise Error, "Too many redirects" if redirects > MAX_REDIRECTS
60
+
61
+ http = Net::HTTP.new(uri.host, uri.port)
62
+ http.use_ssl = uri.scheme == "https"
63
+ http.open_timeout = 10
64
+ http.read_timeout = 60
65
+
66
+ request = Net::HTTP::Post.new(uri)
67
+ request["Content-Type"] = "application/json"
68
+ if @config.ai_auth_style == :bearer
69
+ request["Authorization"] = "Bearer #{@config.ai_api_key}"
70
+ else
71
+ request["api-key"] = @config.ai_api_key
72
+ end
73
+ request.body = body
74
+
75
+ response = http.request(request)
76
+
77
+ if response.is_a?(Net::HTTPRedirection)
78
+ post_with_redirects(URI(response["location"]), body, redirects + 1)
79
+ else
80
+ response
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,56 @@
1
+ module MysqlGenius
2
+ class AiOptimizationService
3
+ def call(sql, explain_rows, allowed_tables)
4
+ schema = build_schema_description(allowed_tables)
5
+ messages = [
6
+ { role: "system", content: system_prompt(schema) },
7
+ { role: "user", content: user_prompt(sql, explain_rows) }
8
+ ]
9
+
10
+ AiClient.new.chat(messages: messages)
11
+ end
12
+
13
+ private
14
+
15
+ def system_prompt(schema_description)
16
+ <<~PROMPT
17
+ You are a MySQL query optimization expert. Given a SQL query and its EXPLAIN output, analyze the query execution plan and provide actionable optimization suggestions.
18
+
19
+ Available schema:
20
+ #{schema_description}
21
+
22
+ Respond with JSON:
23
+ {
24
+ "suggestions": "Markdown-formatted analysis and suggestions. Include: 1) Summary of current execution plan (scan types, rows examined). 2) Specific recommendations such as indexes to add (provide exact CREATE INDEX statements), query rewrites, or structural changes. 3) Expected impact of each suggestion."
25
+ }
26
+ PROMPT
27
+ end
28
+
29
+ def user_prompt(sql, explain_rows)
30
+ <<~PROMPT
31
+ SQL Query:
32
+ #{sql}
33
+
34
+ EXPLAIN Output:
35
+ #{format_explain(explain_rows)}
36
+ PROMPT
37
+ end
38
+
39
+ def format_explain(explain_rows)
40
+ return explain_rows if explain_rows.is_a?(String)
41
+ explain_rows.map { |row| row.join(" | ") }.join("\n")
42
+ end
43
+
44
+ def build_schema_description(allowed_tables)
45
+ connection = ActiveRecord::Base.connection
46
+ allowed_tables.map do |table|
47
+ next unless connection.tables.include?(table)
48
+ columns = connection.columns(table).map { |c| "#{c.name} (#{c.type})" }
49
+ indexes = connection.indexes(table).map { |idx| "#{idx.name}: [#{idx.columns.join(', ')}]#{idx.unique ? ' UNIQUE' : ''}" }
50
+ desc = "#{table}: #{columns.join(', ')}"
51
+ desc += "\n Indexes: #{indexes.join('; ')}" if indexes.any?
52
+ desc
53
+ end.compact.join("\n")
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,56 @@
1
+ module MysqlGenius
2
+ class AiSuggestionService
3
+ def call(user_prompt, allowed_tables)
4
+ schema = build_schema_description(allowed_tables)
5
+ messages = [
6
+ { role: "system", content: system_prompt(schema) },
7
+ { role: "user", content: user_prompt }
8
+ ]
9
+
10
+ AiClient.new.chat(messages: messages)
11
+ end
12
+
13
+ private
14
+
15
+ def system_prompt(schema_description)
16
+ custom_context = MysqlGenius.configuration.ai_system_context
17
+
18
+ prompt = <<~PROMPT
19
+ You are a SQL query assistant for a MySQL database.
20
+ PROMPT
21
+
22
+ if custom_context.present?
23
+ prompt += <<~PROMPT
24
+
25
+ Domain context:
26
+ #{custom_context}
27
+ PROMPT
28
+ end
29
+
30
+ prompt += <<~PROMPT
31
+
32
+ Rules:
33
+ - Only generate SELECT statements. Never generate INSERT, UPDATE, DELETE, or any other mutation.
34
+ - Only reference the tables and columns listed in the schema below. Do not guess or invent column names.
35
+ - Use backticks for table and column names.
36
+ - Include a LIMIT 100 unless the user specifies otherwise.
37
+
38
+ Available schema:
39
+ #{schema_description}
40
+
41
+ Respond with JSON: {"sql": "the SQL query", "explanation": "brief explanation of what the query does"}
42
+ PROMPT
43
+
44
+ prompt
45
+ end
46
+
47
+ def build_schema_description(allowed_tables)
48
+ connection = ActiveRecord::Base.connection
49
+ allowed_tables.map do |table|
50
+ next unless connection.tables.include?(table)
51
+ columns = connection.columns(table).map { |c| "#{c.name} (#{c.type})" }
52
+ "#{table}: #{columns.join(', ')}"
53
+ end.compact.join("\n")
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,116 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title>MySQLGenius</title>
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <meta name="csrf-token" content="<%= form_authenticity_token %>">
7
+ <style>
8
+ *, *::before, *::after { box-sizing: border-box; }
9
+ body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; background: #f5f5f5; color: #333; margin: 0; font-size: 14px; line-height: 1.5; }
10
+ .mg-container { max-width: 1400px; margin: 0 auto; padding: 20px; }
11
+ h4 { margin: 0 0 16px; font-size: 18px; }
12
+
13
+ /* Tabs */
14
+ .mg-tabs { display: flex; border-bottom: 2px solid #dee2e6; margin-bottom: 0; }
15
+ .mg-tab { padding: 8px 16px; cursor: pointer; border: 1px solid transparent; border-bottom: none; margin-bottom: -2px; border-radius: 4px 4px 0 0; background: none; font-size: 14px; color: #555; }
16
+ .mg-tab:hover { color: #000; }
17
+ .mg-tab.active { background: #fff; border-color: #dee2e6; border-bottom-color: #fff; color: #000; font-weight: 500; }
18
+ .mg-tab-content { display: none; background: #fff; border: 1px solid #dee2e6; border-top: none; padding: 16px; }
19
+ .mg-tab-content.active { display: block; }
20
+
21
+ /* Forms */
22
+ label { display: block; font-weight: 500; margin-bottom: 4px; font-size: 13px; }
23
+ select, input[type="number"], input[type="text"], textarea { border: 1px solid #ced4da; border-radius: 4px; padding: 6px 10px; font-size: 13px; width: 100%; font-family: inherit; }
24
+ select:focus, input:focus, textarea:focus { outline: none; border-color: #80bdff; box-shadow: 0 0 0 2px rgba(0,123,255,.15); }
25
+ textarea { font-family: "SF Mono", "Fira Code", "Fira Mono", Menlo, Consolas, monospace; resize: vertical; }
26
+ textarea[readonly] { background: #f8f9fa; }
27
+
28
+ /* Grid helpers */
29
+ .mg-row { display: flex; gap: 12px; align-items: flex-end; flex-wrap: wrap; }
30
+ .mg-col-1 { flex: 0 0 80px; }
31
+ .mg-col-2 { flex: 0 0 160px; }
32
+ .mg-col-3 { flex: 0 0 240px; }
33
+ .mg-col-4 { flex: 0 0 320px; }
34
+ .mg-col-grow { flex: 1 1 200px; }
35
+ .mg-field { margin-bottom: 12px; }
36
+
37
+ /* Buttons */
38
+ .mg-btn { display: inline-flex; align-items: center; gap: 6px; padding: 6px 14px; border: 1px solid transparent; border-radius: 4px; font-size: 13px; cursor: pointer; font-family: inherit; white-space: nowrap; }
39
+ .mg-btn:disabled { opacity: .5; cursor: not-allowed; }
40
+ .mg-btn-primary { background: #007bff; color: #fff; border-color: #007bff; }
41
+ .mg-btn-primary:hover:not(:disabled) { background: #0069d9; }
42
+ .mg-btn-outline { background: #fff; color: #007bff; border-color: #007bff; }
43
+ .mg-btn-outline:hover:not(:disabled) { background: #e7f1ff; }
44
+ .mg-btn-outline-secondary { background: #fff; color: #6c757d; border-color: #6c757d; }
45
+ .mg-btn-outline-secondary:hover:not(:disabled) { background: #f8f9fa; }
46
+ .mg-btn-outline-danger { background: #fff; color: #dc3545; border-color: #dc3545; padding: 4px 8px; }
47
+ .mg-btn-outline-danger:hover:not(:disabled) { background: #ffeef0; }
48
+ .mg-btn-sm { padding: 3px 8px; font-size: 12px; }
49
+
50
+ /* Badge */
51
+ .mg-badge { display: inline-block; padding: 2px 8px; border-radius: 10px; font-size: 12px; font-weight: 500; }
52
+ .mg-badge-info { background: #d1ecf1; color: #0c5460; }
53
+ .mg-badge-warning { background: #fff3cd; color: #856404; }
54
+ .mg-badge-danger { background: #f8d7da; color: #721c24; }
55
+ .mg-badge-secondary { background: #e2e3e5; color: #383d41; }
56
+
57
+ /* Alert */
58
+ .mg-alert { padding: 10px 14px; border-radius: 4px; margin-bottom: 12px; font-size: 13px; }
59
+ .mg-alert-danger { background: #f8d7da; color: #721c24; border: 1px solid #f5c6cb; }
60
+ .mg-alert-warning { background: #fff3cd; color: #856404; border: 1px solid #ffeeba; }
61
+ .mg-alert-info { background: #d1ecf1; color: #0c5460; border: 1px solid #bee5eb; }
62
+
63
+ /* Card */
64
+ .mg-card { border: 1px solid #dee2e6; border-radius: 4px; margin-bottom: 12px; }
65
+ .mg-card-header { padding: 8px 12px; background: #f8f9fa; border-bottom: 1px solid #dee2e6; display: flex; justify-content: space-between; align-items: center; font-size: 13px; }
66
+ .mg-card-body { padding: 12px; }
67
+ .mg-card-toggle { cursor: pointer; color: #007bff; text-decoration: none; font-size: 13px; }
68
+ .mg-card-toggle:hover { text-decoration: underline; }
69
+
70
+ /* Stat grid */
71
+ .mg-stat-grid { display: grid; grid-template-columns: 1fr auto; gap: 4px 12px; font-size: 13px; }
72
+ .mg-stat-grid .mg-stat-label { color: #666; }
73
+ .mg-stat-grid .mg-stat-value { text-align: right; font-weight: 500; }
74
+ .mg-usage-bar { background: #e9ecef; border-radius: 4px; height: 20px; position: relative; overflow: hidden; }
75
+ .mg-usage-bar-fill { height: 100%; border-radius: 4px; transition: width 0.3s; }
76
+ .mg-usage-bar-text { position: absolute; top: 0; left: 0; right: 0; text-align: center; line-height: 20px; font-size: 11px; font-weight: 500; }
77
+
78
+ /* Table */
79
+ .mg-table-wrap { overflow-x: auto; }
80
+ table.mg-table { width: 100%; border-collapse: collapse; font-size: 13px; }
81
+ .mg-table th, .mg-table td { padding: 4px 8px; border: 1px solid #dee2e6; text-align: left; }
82
+ .mg-table th { background: #f8f9fa; font-weight: 500; white-space: nowrap; }
83
+ .mg-table tr:nth-child(even) { background: #f8f9fa; }
84
+ .mg-table em.null { color: #999; font-style: italic; }
85
+ .mg-table .redacted { color: #999; }
86
+
87
+ /* Checkbox grid */
88
+ .mg-checks { display: flex; flex-wrap: wrap; gap: 4px 16px; }
89
+ .mg-check { display: flex; align-items: center; gap: 4px; font-size: 13px; cursor: pointer; }
90
+ .mg-check input { margin: 0; }
91
+ .mg-check .type-hint { color: #999; font-size: 11px; }
92
+
93
+ /* Inline links */
94
+ .mg-link { color: #007bff; cursor: pointer; text-decoration: none; font-size: 12px; margin-left: 8px; }
95
+ .mg-link:hover { text-decoration: underline; }
96
+
97
+ /* Spinner */
98
+ @keyframes mg-spin { to { transform: rotate(360deg); } }
99
+ .mg-spinner { display: inline-block; width: 14px; height: 14px; border: 2px solid #ccc; border-top-color: #007bff; border-radius: 50%; animation: mg-spin .6s linear infinite; }
100
+
101
+ /* Utilities */
102
+ .mg-hidden { display: none !important; }
103
+ .mg-mt { margin-top: 16px; }
104
+ .mg-mb { margin-bottom: 12px; }
105
+ .mg-text-muted { color: #888; font-size: 13px; }
106
+ .mg-text-center { text-align: center; padding: 24px 0; }
107
+ code { font-size: 12px; word-break: break-all; background: #f4f4f4; padding: 1px 4px; border-radius: 2px; }
108
+ pre.mg-pre { background: #f4f4f4; padding: 10px; border-radius: 4px; overflow-x: auto; font-size: 12px; }
109
+ </style>
110
+ </head>
111
+ <body>
112
+ <div class="mg-container">
113
+ <%= yield %>
114
+ </div>
115
+ </body>
116
+ </html>
@@ -0,0 +1,56 @@
1
+ <!-- Explain Results -->
2
+ <div id="explain-results" class="mg-mt mg-hidden">
3
+ <div class="mg-card">
4
+ <div class="mg-card-header">
5
+ <span><strong>&#128270; EXPLAIN Output</strong></span>
6
+ <div>
7
+ <% if @ai_enabled %>
8
+ <button id="explain-optimize" class="mg-btn mg-btn-outline mg-btn-sm">&#9889; AI Optimization</button>
9
+ <button id="explain-index-advisor" class="mg-btn mg-btn-outline mg-btn-sm">&#9889; Index Advisor</button>
10
+ <% end %>
11
+ <button id="explain-close" class="mg-btn mg-btn-outline-secondary mg-btn-sm">&#10005; Close</button>
12
+ </div>
13
+ </div>
14
+ <div class="mg-card-body">
15
+ <div class="mg-table-wrap">
16
+ <table class="mg-table">
17
+ <thead id="explain-thead"></thead>
18
+ <tbody id="explain-tbody"></tbody>
19
+ </table>
20
+ </div>
21
+ <div id="optimize-results" class="mg-hidden mg-mt">
22
+ <div id="optimize-content" class="mg-alert mg-alert-info"></div>
23
+ </div>
24
+ </div>
25
+ </div>
26
+ </div>
27
+
28
+ <!-- Results Area -->
29
+ <div id="query-results" class="mg-mt">
30
+ <div id="results-alert" class="mg-hidden"></div>
31
+ <div id="results-stats" class="mg-mb mg-hidden">
32
+ <span id="results-row-count" class="mg-badge mg-badge-info"></span>
33
+ <span id="results-time" class="mg-badge mg-badge-secondary"></span>
34
+ <span id="results-truncated" class="mg-badge mg-badge-warning mg-hidden">Results truncated</span>
35
+ </div>
36
+ <div id="results-table-wrapper" class="mg-table-wrap mg-hidden">
37
+ <table class="mg-table">
38
+ <thead id="results-thead"></thead>
39
+ <tbody id="results-tbody"></tbody>
40
+ </table>
41
+ </div>
42
+ <div id="results-empty" class="mg-text-center mg-text-muted mg-hidden">No rows returned.</div>
43
+ </div>
44
+
45
+ <!-- AI Query Analysis Results -->
46
+ <div id="ai-query-result" class="mg-mt mg-hidden">
47
+ <div class="mg-card">
48
+ <div class="mg-card-header">
49
+ <span id="ai-query-title"><strong>&#9889; AI Analysis</strong></span>
50
+ <button id="ai-query-close" class="mg-btn mg-btn-outline-secondary mg-btn-sm">&#10005;</button>
51
+ </div>
52
+ <div class="mg-card-body">
53
+ <div id="ai-query-content" style="font-size:13px;"></div>
54
+ </div>
55
+ </div>
56
+ </div>
@@ -0,0 +1,43 @@
1
+ <!-- AI Tools Tab -->
2
+ <div class="mg-tab-content" id="tab-aitools">
3
+ <!-- Schema Review -->
4
+ <div class="mg-card mg-mb">
5
+ <div class="mg-card-header"><strong>&#9889; Schema Review</strong></div>
6
+ <div class="mg-card-body">
7
+ <div class="mg-text-muted mg-mb" style="font-size:12px;">Analyze your schema for anti-patterns: inappropriate column types, missing indexes, naming inconsistencies, and more.</div>
8
+ <div class="mg-row" style="align-items:flex-end;">
9
+ <div class="mg-col-4 mg-field">
10
+ <label for="schema-table">Table (leave blank for all)</label>
11
+ <select id="schema-table">
12
+ <option value="">All tables (top 20)</option>
13
+ <% @all_tables.each do |table| %>
14
+ <option value="<%= table %>"><%= table %></option>
15
+ <% end %>
16
+ </select>
17
+ </div>
18
+ <div class="mg-field">
19
+ <button id="schema-review-btn" class="mg-btn mg-btn-primary mg-btn-sm">&#9889; Analyze Schema</button>
20
+ </div>
21
+ </div>
22
+ <div id="schema-result" class="mg-mt mg-hidden">
23
+ <div id="schema-result-content" style="font-size:13px;"></div>
24
+ </div>
25
+ </div>
26
+ </div>
27
+
28
+ <!-- Migration Risk Assessment -->
29
+ <div class="mg-card">
30
+ <div class="mg-card-header"><strong>&#9889; Migration Risk Assessment</strong></div>
31
+ <div class="mg-card-body">
32
+ <div class="mg-text-muted mg-mb" style="font-size:12px;">Paste a Rails migration or DDL and get a risk assessment: lock duration, impact on active queries, deployment strategy.</div>
33
+ <div class="mg-field">
34
+ <textarea id="migration-input" rows="8" placeholder="class AddIndexToUsers < ActiveRecord::Migration[7.0]&#10; def change&#10; add_index :users, :email, unique: true&#10; end&#10;end"></textarea>
35
+ </div>
36
+ <button id="migration-assess-btn" class="mg-btn mg-btn-primary mg-btn-sm">&#9889; Assess Risk</button>
37
+ <div id="migration-result" class="mg-mt mg-hidden">
38
+ <div id="migration-risk-badge" style="margin-bottom:8px;"></div>
39
+ <div id="migration-result-content" style="font-size:13px;"></div>
40
+ </div>
41
+ </div>
42
+ </div>
43
+ </div>
@@ -0,0 +1,24 @@
1
+ <!-- Duplicate Indexes Tab -->
2
+ <div class="mg-tab-content" id="tab-indexes">
3
+ <div class="mg-row" style="justify-content:space-between;align-items:center;margin-bottom:12px;">
4
+ <div class="mg-text-muted">Indexes whose columns are a left-prefix of another index on the same table. These are redundant and can safely be dropped. <span id="dup-count" class="mg-badge mg-badge-secondary"></span></div>
5
+ <button id="dup-refresh" class="mg-btn mg-btn-outline-secondary mg-btn-sm">&#8635; Refresh</button>
6
+ </div>
7
+ <div id="dup-loading" class="mg-text-center mg-hidden"><span class="mg-spinner"></span> Scanning indexes...</div>
8
+ <div id="dup-empty" class="mg-text-center mg-text-muted mg-hidden">No duplicate indexes found.</div>
9
+ <div id="dup-table-wrapper" class="mg-table-wrap mg-hidden">
10
+ <table class="mg-table">
11
+ <thead>
12
+ <tr>
13
+ <th>Table</th>
14
+ <th>Duplicate Index</th>
15
+ <th>Columns</th>
16
+ <th>Covered By</th>
17
+ <th>Covered Columns</th>
18
+ <th>DROP Statement</th>
19
+ </tr>
20
+ </thead>
21
+ <tbody id="dup-tbody"></tbody>
22
+ </table>
23
+ </div>
24
+ </div>