mysql_genius 0.1.1 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.github/FUNDING.yml +5 -0
- data/.github/workflows/ci.yml +30 -7
- data/.github/workflows/publish.yml +32 -0
- data/.gitignore +3 -0
- data/.rubocop.yml +24 -0
- data/CHANGELOG.md +41 -0
- data/Gemfile +7 -2
- data/README.md +61 -218
- data/Rakefile +3 -1
- data/app/controllers/concerns/mysql_genius/ai_features.rb +90 -52
- data/app/controllers/concerns/mysql_genius/database_analysis.rb +81 -45
- data/app/controllers/concerns/mysql_genius/query_execution.rb +18 -16
- data/app/controllers/mysql_genius/base_controller.rb +3 -1
- data/app/controllers/mysql_genius/queries_controller.rb +19 -12
- data/app/services/mysql_genius/ai_client.rb +9 -2
- data/app/services/mysql_genius/ai_optimization_service.rb +8 -4
- data/app/services/mysql_genius/ai_suggestion_service.rb +5 -2
- data/app/views/layouts/mysql_genius/application.html.erb +147 -5
- data/app/views/mysql_genius/queries/_tab_dashboard.html.erb +95 -0
- data/app/views/mysql_genius/queries/_tab_duplicate_indexes.html.erb +11 -0
- data/app/views/mysql_genius/queries/_tab_query_explorer.html.erb +110 -0
- data/app/views/mysql_genius/queries/_tab_query_stats.html.erb +2 -2
- data/app/views/mysql_genius/queries/_tab_table_sizes.html.erb +7 -5
- data/app/views/mysql_genius/queries/_tab_unused_indexes.html.erb +11 -0
- data/app/views/mysql_genius/queries/index.html.erb +436 -52
- data/bin/console +1 -0
- data/config/routes.rb +2 -0
- data/docs/screenshots/dashboard.png +0 -0
- data/docs/screenshots/query_explore.png +0 -0
- data/docs/superpowers/plans/2026-04-08-dashboard-first-redesign.md +741 -0
- data/docs/superpowers/specs/2026-04-08-dashboard-first-redesign.md +87 -0
- data/lib/generators/mysql_genius/install/install_generator.rb +19 -0
- data/lib/generators/mysql_genius/install/templates/initializer.rb +56 -0
- data/lib/mysql_genius/configuration.rb +8 -6
- data/lib/mysql_genius/engine.rb +2 -0
- data/lib/mysql_genius/slow_query_monitor.rb +29 -25
- data/lib/mysql_genius/sql_validator.rb +6 -4
- data/lib/mysql_genius/version.rb +3 -1
- data/lib/mysql_genius.rb +2 -0
- data/mysql_genius.gemspec +9 -8
- metadata +23 -15
- data/app/views/mysql_genius/queries/_tab_sql_query.html.erb +0 -40
- data/app/views/mysql_genius/queries/_tab_visual_builder.html.erb +0 -61
- data/docs/screenshots/sql_query.png +0 -0
- data/docs/screenshots/visual_builder.png +0 -0
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
module MysqlGenius
|
|
2
4
|
module QueryExecution
|
|
3
5
|
extend ActiveSupport::Concern
|
|
@@ -5,15 +7,15 @@ module MysqlGenius
|
|
|
5
7
|
def execute
|
|
6
8
|
sql = params[:sql].to_s.strip
|
|
7
9
|
row_limit = if params[:row_limit].present?
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
10
|
+
params[:row_limit].to_i.clamp(1, mysql_genius_config.max_row_limit)
|
|
11
|
+
else
|
|
12
|
+
mysql_genius_config.default_row_limit
|
|
13
|
+
end
|
|
12
14
|
|
|
13
15
|
error = validate_sql(sql)
|
|
14
16
|
if error
|
|
15
17
|
audit(:rejection, sql: sql, reason: error)
|
|
16
|
-
return render
|
|
18
|
+
return render(json: { error: error }, status: :unprocessable_entity)
|
|
17
19
|
end
|
|
18
20
|
|
|
19
21
|
limited_sql = apply_row_limit(sql, row_limit)
|
|
@@ -35,20 +37,20 @@ module MysqlGenius
|
|
|
35
37
|
|
|
36
38
|
audit(:query, sql: sql, execution_time_ms: execution_time_ms, row_count: rows.length)
|
|
37
39
|
|
|
38
|
-
render
|
|
40
|
+
render(json: {
|
|
39
41
|
columns: columns,
|
|
40
42
|
rows: rows,
|
|
41
43
|
row_count: rows.length,
|
|
42
44
|
execution_time_ms: execution_time_ms,
|
|
43
|
-
truncated: truncated
|
|
44
|
-
}
|
|
45
|
+
truncated: truncated,
|
|
46
|
+
})
|
|
45
47
|
rescue ActiveRecord::StatementInvalid => e
|
|
46
48
|
if timeout_error?(e)
|
|
47
49
|
audit(:error, sql: sql, error: "Query timeout")
|
|
48
|
-
render
|
|
50
|
+
render(json: { error: "Query exceeded the #{mysql_genius_config.query_timeout_ms / 1000} second timeout limit.", timeout: true }, status: :unprocessable_entity)
|
|
49
51
|
else
|
|
50
52
|
audit(:error, sql: sql, error: e.message)
|
|
51
|
-
render
|
|
53
|
+
render(json: { error: "Query error: #{e.message.split(":").last.strip}" }, status: :unprocessable_entity)
|
|
52
54
|
end
|
|
53
55
|
end
|
|
54
56
|
end
|
|
@@ -59,20 +61,20 @@ module MysqlGenius
|
|
|
59
61
|
|
|
60
62
|
unless skip_validation
|
|
61
63
|
error = validate_sql(sql)
|
|
62
|
-
return render
|
|
64
|
+
return render(json: { error: error }, status: :unprocessable_entity) if error
|
|
63
65
|
end
|
|
64
66
|
|
|
65
67
|
# Reject truncated SQL (captured slow queries are capped at 2000 chars)
|
|
66
68
|
unless sql.match?(/\)\s*$/) || sql.match?(/\w\s*$/) || sql.match?(/['"`]\s*$/) || sql.match?(/\d\s*$/)
|
|
67
|
-
return render
|
|
69
|
+
return render(json: { error: "This query appears to be truncated and cannot be explained." }, status: :unprocessable_entity)
|
|
68
70
|
end
|
|
69
71
|
|
|
70
|
-
explain_sql = "EXPLAIN #{sql.gsub(/;\s*\z/,
|
|
72
|
+
explain_sql = "EXPLAIN #{sql.gsub(/;\s*\z/, "")}"
|
|
71
73
|
results = ActiveRecord::Base.connection.exec_query(explain_sql)
|
|
72
74
|
|
|
73
|
-
render
|
|
75
|
+
render(json: { columns: results.columns, rows: results.rows })
|
|
74
76
|
rescue ActiveRecord::StatementInvalid => e
|
|
75
|
-
render
|
|
77
|
+
render(json: { error: "Explain error: #{e.message.split(":").last.strip}" }, status: :unprocessable_entity)
|
|
76
78
|
end
|
|
77
79
|
|
|
78
80
|
private
|
|
@@ -108,7 +110,7 @@ module MysqlGenius
|
|
|
108
110
|
end
|
|
109
111
|
|
|
110
112
|
def sanitize_ai_sql(sql)
|
|
111
|
-
sql.gsub(/```(?:sql)?\s*/i, "").gsub(
|
|
113
|
+
sql.gsub(/```(?:sql)?\s*/i, "").gsub("```", "").strip
|
|
112
114
|
end
|
|
113
115
|
|
|
114
116
|
def audit(type, **attrs)
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
module MysqlGenius
|
|
2
4
|
class BaseController < MysqlGenius.configuration.base_controller.constantize
|
|
3
5
|
layout "mysql_genius/application"
|
|
@@ -7,7 +9,7 @@ module MysqlGenius
|
|
|
7
9
|
|
|
8
10
|
def authenticate_mysql_genius!
|
|
9
11
|
unless MysqlGenius.configuration.authenticate.call(self)
|
|
10
|
-
render
|
|
12
|
+
render(plain: "Not authorized", status: :unauthorized)
|
|
11
13
|
end
|
|
12
14
|
end
|
|
13
15
|
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
module MysqlGenius
|
|
2
4
|
class QueriesController < BaseController
|
|
3
5
|
include QueryExecution
|
|
@@ -6,10 +8,10 @@ module MysqlGenius
|
|
|
6
8
|
|
|
7
9
|
def index
|
|
8
10
|
@featured_tables = if mysql_genius_config.featured_tables.any?
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
11
|
+
mysql_genius_config.featured_tables.sort
|
|
12
|
+
else
|
|
13
|
+
queryable_tables.sort
|
|
14
|
+
end
|
|
13
15
|
@all_tables = queryable_tables.sort
|
|
14
16
|
@ai_enabled = mysql_genius_config.ai_enabled?
|
|
15
17
|
end
|
|
@@ -17,32 +19,37 @@ module MysqlGenius
|
|
|
17
19
|
def columns
|
|
18
20
|
table = params[:table]
|
|
19
21
|
if mysql_genius_config.blocked_tables.include?(table)
|
|
20
|
-
return render
|
|
22
|
+
return render(json: { error: "Table '#{table}' is not available for querying." }, status: :forbidden)
|
|
21
23
|
end
|
|
22
24
|
|
|
23
25
|
unless ActiveRecord::Base.connection.tables.include?(table)
|
|
24
|
-
return render
|
|
26
|
+
return render(json: { error: "Table '#{table}' does not exist." }, status: :not_found)
|
|
25
27
|
end
|
|
26
28
|
|
|
27
29
|
defaults = mysql_genius_config.default_columns[table] || []
|
|
28
30
|
cols = ActiveRecord::Base.connection.columns(table).reject { |c| masked_column?(c.name) }.map do |c|
|
|
29
31
|
{ name: c.name, type: c.type.to_s, default: defaults.empty? || defaults.include?(c.name) }
|
|
30
32
|
end
|
|
31
|
-
render
|
|
33
|
+
render(json: cols)
|
|
32
34
|
end
|
|
33
35
|
|
|
34
36
|
def slow_queries
|
|
35
37
|
unless mysql_genius_config.redis_url.present?
|
|
36
|
-
return render
|
|
38
|
+
return render(json: [], status: :ok)
|
|
37
39
|
end
|
|
38
40
|
|
|
41
|
+
require "redis"
|
|
39
42
|
redis = Redis.new(url: mysql_genius_config.redis_url)
|
|
40
43
|
key = SlowQueryMonitor.redis_key
|
|
41
44
|
raw = redis.lrange(key, 0, 199)
|
|
42
|
-
queries = raw.map
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
45
|
+
queries = raw.map do |entry|
|
|
46
|
+
JSON.parse(entry)
|
|
47
|
+
rescue JSON::ParserError
|
|
48
|
+
nil
|
|
49
|
+
end.compact
|
|
50
|
+
render(json: queries)
|
|
51
|
+
rescue StandardError => e
|
|
52
|
+
render(json: { error: "Slow query error: #{e.message}" }, status: :unprocessable_entity)
|
|
46
53
|
end
|
|
47
54
|
|
|
48
55
|
private
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
require "net/http"
|
|
2
4
|
require "json"
|
|
3
5
|
require "uri"
|
|
@@ -20,7 +22,7 @@ module MysqlGenius
|
|
|
20
22
|
body = {
|
|
21
23
|
messages: messages,
|
|
22
24
|
response_format: { type: "json_object" },
|
|
23
|
-
temperature: temperature
|
|
25
|
+
temperature: temperature,
|
|
24
26
|
}
|
|
25
27
|
body[:model] = @config.ai_model if @config.ai_model && !@config.ai_model.empty?
|
|
26
28
|
|
|
@@ -28,7 +30,7 @@ module MysqlGenius
|
|
|
28
30
|
parsed = JSON.parse(response.body)
|
|
29
31
|
|
|
30
32
|
if parsed["error"]
|
|
31
|
-
raise Error, "AI API error: #{parsed[
|
|
33
|
+
raise Error, "AI API error: #{parsed["error"]["message"] || parsed["error"]}"
|
|
32
34
|
end
|
|
33
35
|
|
|
34
36
|
content = parsed.dig("choices", 0, "message", "content")
|
|
@@ -60,6 +62,11 @@ module MysqlGenius
|
|
|
60
62
|
|
|
61
63
|
http = Net::HTTP.new(uri.host, uri.port)
|
|
62
64
|
http.use_ssl = uri.scheme == "https"
|
|
65
|
+
if http.use_ssl?
|
|
66
|
+
http.verify_mode = OpenSSL::SSL::VERIFY_PEER
|
|
67
|
+
cert_file = ENV["SSL_CERT_FILE"] || OpenSSL::X509::DEFAULT_CERT_FILE
|
|
68
|
+
http.ca_file = cert_file if File.exist?(cert_file)
|
|
69
|
+
end
|
|
63
70
|
http.open_timeout = 10
|
|
64
71
|
http.read_timeout = 60
|
|
65
72
|
|
|
@@ -1,10 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
module MysqlGenius
|
|
2
4
|
class AiOptimizationService
|
|
3
5
|
def call(sql, explain_rows, allowed_tables)
|
|
4
6
|
schema = build_schema_description(allowed_tables)
|
|
5
7
|
messages = [
|
|
6
8
|
{ role: "system", content: system_prompt(schema) },
|
|
7
|
-
{ role: "user", content: user_prompt(sql, explain_rows) }
|
|
9
|
+
{ role: "user", content: user_prompt(sql, explain_rows) },
|
|
8
10
|
]
|
|
9
11
|
|
|
10
12
|
AiClient.new.chat(messages: messages)
|
|
@@ -38,6 +40,7 @@ module MysqlGenius
|
|
|
38
40
|
|
|
39
41
|
def format_explain(explain_rows)
|
|
40
42
|
return explain_rows if explain_rows.is_a?(String)
|
|
43
|
+
|
|
41
44
|
explain_rows.map { |row| row.join(" | ") }.join("\n")
|
|
42
45
|
end
|
|
43
46
|
|
|
@@ -45,10 +48,11 @@ module MysqlGenius
|
|
|
45
48
|
connection = ActiveRecord::Base.connection
|
|
46
49
|
allowed_tables.map do |table|
|
|
47
50
|
next unless connection.tables.include?(table)
|
|
51
|
+
|
|
48
52
|
columns = connection.columns(table).map { |c| "#{c.name} (#{c.type})" }
|
|
49
|
-
indexes = connection.indexes(table).map { |idx| "#{idx.name}: [#{idx.columns.join(
|
|
50
|
-
desc = "#{table}: #{columns.join(
|
|
51
|
-
desc += "\n Indexes: #{indexes.join(
|
|
53
|
+
indexes = connection.indexes(table).map { |idx| "#{idx.name}: [#{idx.columns.join(", ")}]#{" UNIQUE" if idx.unique}" }
|
|
54
|
+
desc = "#{table}: #{columns.join(", ")}"
|
|
55
|
+
desc += "\n Indexes: #{indexes.join("; ")}" if indexes.any?
|
|
52
56
|
desc
|
|
53
57
|
end.compact.join("\n")
|
|
54
58
|
end
|
|
@@ -1,10 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
module MysqlGenius
|
|
2
4
|
class AiSuggestionService
|
|
3
5
|
def call(user_prompt, allowed_tables)
|
|
4
6
|
schema = build_schema_description(allowed_tables)
|
|
5
7
|
messages = [
|
|
6
8
|
{ role: "system", content: system_prompt(schema) },
|
|
7
|
-
{ role: "user", content: user_prompt }
|
|
9
|
+
{ role: "user", content: user_prompt },
|
|
8
10
|
]
|
|
9
11
|
|
|
10
12
|
AiClient.new.chat(messages: messages)
|
|
@@ -48,8 +50,9 @@ module MysqlGenius
|
|
|
48
50
|
connection = ActiveRecord::Base.connection
|
|
49
51
|
allowed_tables.map do |table|
|
|
50
52
|
next unless connection.tables.include?(table)
|
|
53
|
+
|
|
51
54
|
columns = connection.columns(table).map { |c| "#{c.name} (#{c.type})" }
|
|
52
|
-
"#{table}: #{columns.join(
|
|
55
|
+
"#{table}: #{columns.join(", ")}"
|
|
53
56
|
end.compact.join("\n")
|
|
54
57
|
end
|
|
55
58
|
end
|
|
@@ -53,6 +53,7 @@
|
|
|
53
53
|
.mg-badge-warning { background: #fff3cd; color: #856404; }
|
|
54
54
|
.mg-badge-danger { background: #f8d7da; color: #721c24; }
|
|
55
55
|
.mg-badge-secondary { background: #e2e3e5; color: #383d41; }
|
|
56
|
+
.mg-badge-success { background: #d4edda; color: #155724; }
|
|
56
57
|
|
|
57
58
|
/* Alert */
|
|
58
59
|
.mg-alert { padding: 10px 14px; border-radius: 4px; margin-bottom: 12px; font-size: 13px; }
|
|
@@ -77,13 +78,50 @@
|
|
|
77
78
|
|
|
78
79
|
/* Table */
|
|
79
80
|
.mg-table-wrap { overflow-x: auto; }
|
|
80
|
-
table.mg-table { width: 100%; border-collapse:
|
|
81
|
-
.mg-table th
|
|
82
|
-
.mg-table th {
|
|
83
|
-
.mg-table
|
|
81
|
+
table.mg-table { width: 100%; border-collapse: separate; border-spacing: 0; font-size: 13px; }
|
|
82
|
+
.mg-table th { padding: 10px 12px; text-align: left; white-space: nowrap; font-weight: 600; font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; color: #5a6770; background: #f1f3f5; border-bottom: 2px solid #d0d7de; user-select: none; }
|
|
83
|
+
.mg-table th.mg-sortable { cursor: pointer; position: relative; padding-right: 20px; }
|
|
84
|
+
.mg-table th.mg-sortable:hover { color: #333; }
|
|
85
|
+
.mg-table th.mg-sortable::after { content: '\2195'; position: absolute; right: 6px; opacity: 0.3; font-size: 10px; }
|
|
86
|
+
.mg-table th.mg-sort-asc::after { content: '\2191'; opacity: 0.8; }
|
|
87
|
+
.mg-table th.mg-sort-desc::after { content: '\2193'; opacity: 0.8; }
|
|
88
|
+
.mg-table th:first-child { border-top-left-radius: 6px; }
|
|
89
|
+
.mg-table th:last-child { border-top-right-radius: 6px; }
|
|
90
|
+
.mg-table td { padding: 8px 12px; border-bottom: 1px solid #eaecef; vertical-align: top; }
|
|
91
|
+
.mg-table tbody tr { transition: background-color 0.15s ease; }
|
|
92
|
+
.mg-table tbody tr:hover { background: #f0f6ff; }
|
|
93
|
+
.mg-table tbody tr:nth-child(even) { background: #fafbfc; }
|
|
94
|
+
.mg-table tbody tr:nth-child(even):hover { background: #f0f6ff; }
|
|
95
|
+
.mg-table tbody tr:last-child td { border-bottom: none; }
|
|
84
96
|
.mg-table em.null { color: #999; font-style: italic; }
|
|
85
97
|
.mg-table .redacted { color: #999; }
|
|
86
98
|
|
|
99
|
+
/* Numeric table cells */
|
|
100
|
+
.mg-table td.mg-num { text-align: right; font-variant-numeric: tabular-nums; font-family: "SF Mono", "Fira Code", "Fira Mono", Menlo, Consolas, monospace; font-size: 12px; white-space: nowrap; }
|
|
101
|
+
|
|
102
|
+
/* SQL code blocks in tables */
|
|
103
|
+
.mg-sql-block { display: block; padding: 6px 8px; background: #1e1e2e; color: #cdd6f4; border-radius: 5px; font-family: "SF Mono", "Fira Code", "Fira Mono", Menlo, Consolas, monospace; font-size: 11.5px; line-height: 1.5; word-break: break-word; white-space: pre-wrap; max-height: 120px; overflow-y: auto; border: 1px solid #313244; }
|
|
104
|
+
.mg-sql-block::-webkit-scrollbar { width: 4px; }
|
|
105
|
+
.mg-sql-block::-webkit-scrollbar-track { background: transparent; }
|
|
106
|
+
.mg-sql-block::-webkit-scrollbar-thumb { background: #585b70; border-radius: 2px; }
|
|
107
|
+
|
|
108
|
+
/* SQL syntax colors (Catppuccin Mocha inspired) */
|
|
109
|
+
.mg-sql-kw { color: #cba6f7; font-weight: 600; }
|
|
110
|
+
.mg-sql-fn { color: #89b4fa; }
|
|
111
|
+
.mg-sql-str { color: #a6e3a1; }
|
|
112
|
+
.mg-sql-num { color: #fab387; }
|
|
113
|
+
.mg-sql-op { color: #89dceb; }
|
|
114
|
+
.mg-sql-comment { color: #6c7086; font-style: italic; }
|
|
115
|
+
.mg-sql-tbl { color: #f9e2af; }
|
|
116
|
+
.mg-sql-star { color: #f38ba8; font-weight: 700; }
|
|
117
|
+
.mg-sql-punc { color: #9399b2; }
|
|
118
|
+
.mg-sql-placeholder { color: #74c7ec; font-style: italic; }
|
|
119
|
+
|
|
120
|
+
/* Duration color coding */
|
|
121
|
+
.mg-dur-fast { color: #1a7f37; }
|
|
122
|
+
.mg-dur-moderate { color: #9a6700; }
|
|
123
|
+
.mg-dur-slow { color: #cf222e; font-weight: 600; }
|
|
124
|
+
|
|
87
125
|
/* Checkbox grid */
|
|
88
126
|
.mg-checks { display: flex; flex-wrap: wrap; gap: 4px 16px; }
|
|
89
127
|
.mg-check { display: flex; align-items: center; gap: 4px; font-size: 13px; cursor: pointer; }
|
|
@@ -104,11 +142,115 @@
|
|
|
104
142
|
.mg-mb { margin-bottom: 12px; }
|
|
105
143
|
.mg-text-muted { color: #888; font-size: 13px; }
|
|
106
144
|
.mg-text-center { text-align: center; padding: 24px 0; }
|
|
107
|
-
code { font-size: 12px; word-break: break-all; background: #
|
|
145
|
+
code { font-size: 12px; word-break: break-all; background: #f0f1f3; padding: 2px 6px; border-radius: 3px; color: #24292f; }
|
|
108
146
|
pre.mg-pre { background: #f4f4f4; padding: 10px; border-radius: 4px; overflow-x: auto; font-size: 12px; }
|
|
147
|
+
|
|
148
|
+
/* Theme toggle */
|
|
149
|
+
.mg-theme-toggle { background: none; border: 1px solid #ced4da; border-radius: 4px; padding: 4px 8px; cursor: pointer; font-size: 14px; line-height: 1; color: inherit; }
|
|
150
|
+
.mg-theme-toggle:hover { background: #e9ecef; }
|
|
151
|
+
|
|
152
|
+
/* --- Dark Theme --- */
|
|
153
|
+
[data-theme="dark"] body,
|
|
154
|
+
[data-theme="dark"] { background: #161b22; color: #c9d1d9; }
|
|
155
|
+
[data-theme="dark"] .mg-container { color: #c9d1d9; }
|
|
156
|
+
|
|
157
|
+
/* Tabs */
|
|
158
|
+
[data-theme="dark"] .mg-tabs { border-bottom-color: #30363d; }
|
|
159
|
+
[data-theme="dark"] .mg-tab { color: #8b949e; }
|
|
160
|
+
[data-theme="dark"] .mg-tab:hover { color: #c9d1d9; }
|
|
161
|
+
[data-theme="dark"] .mg-tab.active { background: #0d1117; border-color: #30363d; border-bottom-color: #0d1117; color: #c9d1d9; }
|
|
162
|
+
[data-theme="dark"] .mg-tab-content { background: #0d1117; border-color: #30363d; }
|
|
163
|
+
|
|
164
|
+
/* Forms */
|
|
165
|
+
[data-theme="dark"] select,
|
|
166
|
+
[data-theme="dark"] input[type="number"],
|
|
167
|
+
[data-theme="dark"] input[type="text"],
|
|
168
|
+
[data-theme="dark"] textarea { background: #0d1117; color: #c9d1d9; border-color: #30363d; }
|
|
169
|
+
[data-theme="dark"] select:focus,
|
|
170
|
+
[data-theme="dark"] input:focus,
|
|
171
|
+
[data-theme="dark"] textarea:focus { border-color: #58a6ff; box-shadow: 0 0 0 2px rgba(88,166,255,.15); }
|
|
172
|
+
[data-theme="dark"] textarea[readonly] { background: #161b22; }
|
|
173
|
+
[data-theme="dark"] label { color: #c9d1d9; }
|
|
174
|
+
|
|
175
|
+
/* Buttons */
|
|
176
|
+
[data-theme="dark"] .mg-btn-primary { background: #238636; border-color: #238636; }
|
|
177
|
+
[data-theme="dark"] .mg-btn-primary:hover:not(:disabled) { background: #2ea043; }
|
|
178
|
+
[data-theme="dark"] .mg-btn-outline { background: #0d1117; color: #58a6ff; border-color: #30363d; }
|
|
179
|
+
[data-theme="dark"] .mg-btn-outline:hover:not(:disabled) { background: #161b22; border-color: #58a6ff; }
|
|
180
|
+
[data-theme="dark"] .mg-btn-outline-secondary { background: #0d1117; color: #8b949e; border-color: #30363d; }
|
|
181
|
+
[data-theme="dark"] .mg-btn-outline-secondary:hover:not(:disabled) { background: #161b22; }
|
|
182
|
+
[data-theme="dark"] .mg-btn-outline-danger { background: #0d1117; color: #f85149; border-color: #30363d; }
|
|
183
|
+
[data-theme="dark"] .mg-btn-outline-danger:hover:not(:disabled) { background: #1c0d0d; }
|
|
184
|
+
|
|
185
|
+
/* Badges */
|
|
186
|
+
[data-theme="dark"] .mg-badge-info { background: #0d2a3a; color: #58a6ff; }
|
|
187
|
+
[data-theme="dark"] .mg-badge-warning { background: #2d2000; color: #d29922; }
|
|
188
|
+
[data-theme="dark"] .mg-badge-danger { background: #2d0d0d; color: #f85149; }
|
|
189
|
+
[data-theme="dark"] .mg-badge-secondary { background: #21262d; color: #8b949e; }
|
|
190
|
+
[data-theme="dark"] .mg-badge-success { background: #0d2d1a; color: #3fb950; }
|
|
191
|
+
|
|
192
|
+
/* Alerts */
|
|
193
|
+
[data-theme="dark"] .mg-alert-danger { background: #2d0d0d; color: #f85149; border-color: #3d1414; }
|
|
194
|
+
[data-theme="dark"] .mg-alert-warning { background: #2d2000; color: #d29922; border-color: #3d2e00; }
|
|
195
|
+
[data-theme="dark"] .mg-alert-info { background: #0d2a3a; color: #58a6ff; border-color: #0d3a5a; }
|
|
196
|
+
|
|
197
|
+
/* Cards */
|
|
198
|
+
[data-theme="dark"] .mg-card { border-color: #30363d; }
|
|
199
|
+
[data-theme="dark"] .mg-card-header { background: #161b22; border-bottom-color: #30363d; color: #c9d1d9; }
|
|
200
|
+
[data-theme="dark"] .mg-card-toggle { color: #58a6ff; }
|
|
201
|
+
|
|
202
|
+
/* Stat grid */
|
|
203
|
+
[data-theme="dark"] .mg-stat-grid .mg-stat-label { color: #8b949e; }
|
|
204
|
+
[data-theme="dark"] .mg-usage-bar { background: #21262d; }
|
|
205
|
+
|
|
206
|
+
/* Tables */
|
|
207
|
+
[data-theme="dark"] .mg-table th { background: #161b22; color: #8b949e; border-bottom-color: #30363d; }
|
|
208
|
+
[data-theme="dark"] .mg-table th.mg-sortable:hover { color: #c9d1d9; }
|
|
209
|
+
[data-theme="dark"] .mg-table td { border-bottom-color: #21262d; }
|
|
210
|
+
[data-theme="dark"] .mg-table tbody tr:hover { background: #1c2128; }
|
|
211
|
+
[data-theme="dark"] .mg-table tbody tr:nth-child(even) { background: #0d1117; }
|
|
212
|
+
[data-theme="dark"] .mg-table tbody tr:nth-child(even):hover { background: #1c2128; }
|
|
213
|
+
[data-theme="dark"] .mg-table em.null { color: #484f58; }
|
|
214
|
+
[data-theme="dark"] .mg-table .redacted { color: #484f58; }
|
|
215
|
+
|
|
216
|
+
/* Duration colors (dark) */
|
|
217
|
+
[data-theme="dark"] .mg-dur-fast { color: #3fb950; }
|
|
218
|
+
[data-theme="dark"] .mg-dur-moderate { color: #d29922; }
|
|
219
|
+
[data-theme="dark"] .mg-dur-slow { color: #f85149; }
|
|
220
|
+
|
|
221
|
+
/* Inline code */
|
|
222
|
+
[data-theme="dark"] code { background: #21262d; color: #c9d1d9; }
|
|
223
|
+
[data-theme="dark"] pre.mg-pre { background: #161b22; color: #c9d1d9; }
|
|
224
|
+
|
|
225
|
+
/* Links */
|
|
226
|
+
[data-theme="dark"] .mg-link { color: #58a6ff; }
|
|
227
|
+
|
|
228
|
+
/* Spinner */
|
|
229
|
+
[data-theme="dark"] .mg-spinner { border-color: #30363d; border-top-color: #58a6ff; }
|
|
230
|
+
|
|
231
|
+
/* Text */
|
|
232
|
+
[data-theme="dark"] .mg-text-muted { color: #8b949e; }
|
|
233
|
+
|
|
234
|
+
/* Checkboxes */
|
|
235
|
+
[data-theme="dark"] .mg-check .type-hint { color: #484f58; }
|
|
236
|
+
|
|
237
|
+
/* Theme toggle (dark) */
|
|
238
|
+
[data-theme="dark"] .mg-theme-toggle { border-color: #30363d; color: #c9d1d9; }
|
|
239
|
+
[data-theme="dark"] .mg-theme-toggle:hover { background: #21262d; }
|
|
240
|
+
|
|
241
|
+
/* Database switcher (dark) */
|
|
242
|
+
[data-theme="dark"] .mg-db-switcher select { background: #0d1117; color: #c9d1d9; border-color: #30363d; }
|
|
243
|
+
[data-theme="dark"] .mg-db-badge { background: #0d2a3a; color: #58a6ff; }
|
|
109
244
|
</style>
|
|
110
245
|
</head>
|
|
111
246
|
<body>
|
|
247
|
+
<script>
|
|
248
|
+
(function() {
|
|
249
|
+
var saved = localStorage.getItem('mg-theme');
|
|
250
|
+
var theme = saved || (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
|
|
251
|
+
document.documentElement.setAttribute('data-theme', theme);
|
|
252
|
+
})();
|
|
253
|
+
</script>
|
|
112
254
|
<div class="mg-container">
|
|
113
255
|
<%= yield %>
|
|
114
256
|
</div>
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
<!-- Dashboard Tab -->
|
|
2
|
+
<div class="mg-tab-content active" id="tab-dashboard">
|
|
3
|
+
<div id="dash-loading" class="mg-text-center"><span class="mg-spinner"></span> Loading dashboard...</div>
|
|
4
|
+
<div id="dash-error" class="mg-hidden"></div>
|
|
5
|
+
<div id="dash-content" class="mg-hidden">
|
|
6
|
+
|
|
7
|
+
<!-- Server Summary -->
|
|
8
|
+
<div class="mg-card mg-mb">
|
|
9
|
+
<div class="mg-card-header" style="display:flex;justify-content:space-between;align-items:center;">
|
|
10
|
+
<strong>Server</strong>
|
|
11
|
+
<button class="mg-btn mg-btn-outline-secondary mg-btn-sm dash-jump-tab" data-target="server">Details →</button>
|
|
12
|
+
</div>
|
|
13
|
+
</div>
|
|
14
|
+
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:12px;margin-bottom:16px;">
|
|
15
|
+
<div class="mg-card">
|
|
16
|
+
<div class="mg-card-header"><strong>Overview</strong></div>
|
|
17
|
+
<div class="mg-card-body">
|
|
18
|
+
<div class="mg-stat-grid" id="dash-server-info"></div>
|
|
19
|
+
</div>
|
|
20
|
+
</div>
|
|
21
|
+
<div class="mg-card">
|
|
22
|
+
<div class="mg-card-header"><strong>Connections</strong></div>
|
|
23
|
+
<div class="mg-card-body">
|
|
24
|
+
<div id="dash-conn-bar" style="margin-bottom:8px;"></div>
|
|
25
|
+
<div class="mg-stat-grid" id="dash-conn-info"></div>
|
|
26
|
+
</div>
|
|
27
|
+
</div>
|
|
28
|
+
<div class="mg-card">
|
|
29
|
+
<div class="mg-card-header"><strong>InnoDB Buffer Pool</strong></div>
|
|
30
|
+
<div class="mg-card-body">
|
|
31
|
+
<div id="dash-innodb-bar" style="margin-bottom:8px;"></div>
|
|
32
|
+
<div class="mg-stat-grid" id="dash-innodb-info"></div>
|
|
33
|
+
</div>
|
|
34
|
+
</div>
|
|
35
|
+
<div class="mg-card">
|
|
36
|
+
<div class="mg-card-header"><strong>Query Activity</strong></div>
|
|
37
|
+
<div class="mg-card-body">
|
|
38
|
+
<div class="mg-stat-grid" id="dash-query-info"></div>
|
|
39
|
+
</div>
|
|
40
|
+
</div>
|
|
41
|
+
</div>
|
|
42
|
+
|
|
43
|
+
<!-- Top 5 Slow Queries -->
|
|
44
|
+
<div class="mg-card mg-mb">
|
|
45
|
+
<div class="mg-card-header" style="display:flex;justify-content:space-between;align-items:center;">
|
|
46
|
+
<strong>Slow Queries</strong>
|
|
47
|
+
<button class="mg-btn mg-btn-outline-secondary mg-btn-sm dash-jump-tab" data-target="slow">View all →</button>
|
|
48
|
+
</div>
|
|
49
|
+
<div class="mg-card-body">
|
|
50
|
+
<div id="dash-slow-empty" class="mg-text-muted mg-hidden"></div>
|
|
51
|
+
<div id="dash-slow-table" class="mg-table-wrap mg-hidden">
|
|
52
|
+
<table class="mg-table">
|
|
53
|
+
<thead><tr><th style="width:100px">Duration</th><th style="width:160px">Time</th><th>SQL</th></tr></thead>
|
|
54
|
+
<tbody id="dash-slow-tbody"></tbody>
|
|
55
|
+
</table>
|
|
56
|
+
</div>
|
|
57
|
+
</div>
|
|
58
|
+
</div>
|
|
59
|
+
|
|
60
|
+
<!-- Top 5 Expensive Queries -->
|
|
61
|
+
<div class="mg-card mg-mb">
|
|
62
|
+
<div class="mg-card-header" style="display:flex;justify-content:space-between;align-items:center;">
|
|
63
|
+
<strong>Most Expensive Queries</strong>
|
|
64
|
+
<button class="mg-btn mg-btn-outline-secondary mg-btn-sm dash-jump-tab" data-target="qstats">View all →</button>
|
|
65
|
+
</div>
|
|
66
|
+
<div class="mg-card-body">
|
|
67
|
+
<div id="dash-qstats-empty" class="mg-text-muted mg-hidden"></div>
|
|
68
|
+
<div id="dash-qstats-error" class="mg-hidden"></div>
|
|
69
|
+
<div id="dash-qstats-table" class="mg-table-wrap mg-hidden">
|
|
70
|
+
<table class="mg-table">
|
|
71
|
+
<thead><tr><th>Query</th><th style="text-align:right">Calls</th><th style="text-align:right">Total Time</th><th style="text-align:right">Avg Time</th></tr></thead>
|
|
72
|
+
<tbody id="dash-qstats-tbody"></tbody>
|
|
73
|
+
</table>
|
|
74
|
+
</div>
|
|
75
|
+
</div>
|
|
76
|
+
</div>
|
|
77
|
+
|
|
78
|
+
<!-- Index Alerts -->
|
|
79
|
+
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:12px;">
|
|
80
|
+
<div class="mg-card dash-jump-tab" data-target="indexes" style="cursor:pointer;">
|
|
81
|
+
<div class="mg-card-body mg-text-center">
|
|
82
|
+
<div id="dash-dup-count" style="font-size:24px;font-weight:700;">--</div>
|
|
83
|
+
<div class="mg-text-muted">Duplicate Indexes</div>
|
|
84
|
+
</div>
|
|
85
|
+
</div>
|
|
86
|
+
<div class="mg-card dash-jump-tab" data-target="unused" style="cursor:pointer;">
|
|
87
|
+
<div class="mg-card-body mg-text-center">
|
|
88
|
+
<div id="dash-unused-count" style="font-size:24px;font-weight:700;">--</div>
|
|
89
|
+
<div class="mg-text-muted">Unused Indexes</div>
|
|
90
|
+
</div>
|
|
91
|
+
</div>
|
|
92
|
+
</div>
|
|
93
|
+
|
|
94
|
+
</div>
|
|
95
|
+
</div>
|
|
@@ -21,4 +21,15 @@
|
|
|
21
21
|
<tbody id="dup-tbody"></tbody>
|
|
22
22
|
</table>
|
|
23
23
|
</div>
|
|
24
|
+
<div id="dup-migration" class="mg-hidden" style="margin-top:16px;">
|
|
25
|
+
<div class="mg-card">
|
|
26
|
+
<div class="mg-card-header" style="display:flex;justify-content:space-between;align-items:center;">
|
|
27
|
+
<strong>Suggested Migration</strong>
|
|
28
|
+
<button id="dup-copy-migration" class="mg-btn mg-btn-outline-secondary mg-btn-sm">📋 Copy</button>
|
|
29
|
+
</div>
|
|
30
|
+
<div class="mg-card-body">
|
|
31
|
+
<pre id="dup-migration-code" style="font-size:12px;margin:0;white-space:pre-wrap;user-select:all;"></pre>
|
|
32
|
+
</div>
|
|
33
|
+
</div>
|
|
34
|
+
</div>
|
|
24
35
|
</div>
|