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.
- checksums.yaml +7 -0
- data/.github/workflows/ci.yml +53 -0
- data/.gitignore +11 -0
- data/.rspec +3 -0
- data/CHANGELOG.md +13 -0
- data/Gemfile +15 -0
- data/LICENSE.txt +21 -0
- data/README.md +295 -0
- data/Rakefile +6 -0
- data/app/controllers/concerns/mysql_genius/ai_features.rb +360 -0
- data/app/controllers/concerns/mysql_genius/database_analysis.rb +259 -0
- data/app/controllers/concerns/mysql_genius/query_execution.rb +129 -0
- data/app/controllers/mysql_genius/base_controller.rb +18 -0
- data/app/controllers/mysql_genius/queries_controller.rb +54 -0
- data/app/services/mysql_genius/ai_client.rb +84 -0
- data/app/services/mysql_genius/ai_optimization_service.rb +56 -0
- data/app/services/mysql_genius/ai_suggestion_service.rb +56 -0
- data/app/views/layouts/mysql_genius/application.html.erb +116 -0
- data/app/views/mysql_genius/queries/_shared_results.html.erb +56 -0
- data/app/views/mysql_genius/queries/_tab_ai_tools.html.erb +43 -0
- data/app/views/mysql_genius/queries/_tab_duplicate_indexes.html.erb +24 -0
- data/app/views/mysql_genius/queries/_tab_query_stats.html.erb +36 -0
- data/app/views/mysql_genius/queries/_tab_server.html.erb +54 -0
- data/app/views/mysql_genius/queries/_tab_slow_queries.html.erb +17 -0
- data/app/views/mysql_genius/queries/_tab_sql_query.html.erb +40 -0
- data/app/views/mysql_genius/queries/_tab_table_sizes.html.erb +31 -0
- data/app/views/mysql_genius/queries/_tab_unused_indexes.html.erb +25 -0
- data/app/views/mysql_genius/queries/_tab_visual_builder.html.erb +61 -0
- data/app/views/mysql_genius/queries/index.html.erb +1185 -0
- data/bin/console +14 -0
- data/bin/setup +8 -0
- data/config/routes.rb +24 -0
- data/docs/screenshots/ai_tools.png +0 -0
- data/docs/screenshots/duplicate_indexes.png +0 -0
- data/docs/screenshots/query_stats.png +0 -0
- data/docs/screenshots/server.png +0 -0
- data/docs/screenshots/sql_query.png +0 -0
- data/docs/screenshots/table_sizes.png +0 -0
- data/docs/screenshots/visual_builder.png +0 -0
- data/lib/mysql_genius/configuration.rb +96 -0
- data/lib/mysql_genius/engine.rb +12 -0
- data/lib/mysql_genius/slow_query_monitor.rb +38 -0
- data/lib/mysql_genius/sql_validator.rb +55 -0
- data/lib/mysql_genius/version.rb +3 -0
- data/lib/mysql_genius.rb +23 -0
- data/mysql_genius.gemspec +34 -0
- 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>🔎 EXPLAIN Output</strong></span>
|
|
6
|
+
<div>
|
|
7
|
+
<% if @ai_enabled %>
|
|
8
|
+
<button id="explain-optimize" class="mg-btn mg-btn-outline mg-btn-sm">⚡ AI Optimization</button>
|
|
9
|
+
<button id="explain-index-advisor" class="mg-btn mg-btn-outline mg-btn-sm">⚡ Index Advisor</button>
|
|
10
|
+
<% end %>
|
|
11
|
+
<button id="explain-close" class="mg-btn mg-btn-outline-secondary mg-btn-sm">✕ 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>⚡ AI Analysis</strong></span>
|
|
50
|
+
<button id="ai-query-close" class="mg-btn mg-btn-outline-secondary mg-btn-sm">✕</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>⚡ 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">⚡ 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>⚡ 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] def change add_index :users, :email, unique: true end end"></textarea>
|
|
35
|
+
</div>
|
|
36
|
+
<button id="migration-assess-btn" class="mg-btn mg-btn-primary mg-btn-sm">⚡ 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">↻ 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>
|