mysql_genius 0.1.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/.github/FUNDING.yml +5 -0
  3. data/.github/workflows/ci.yml +30 -7
  4. data/.gitignore +2 -0
  5. data/.rubocop.yml +24 -0
  6. data/CHANGELOG.md +32 -0
  7. data/Gemfile +7 -2
  8. data/README.md +50 -38
  9. data/Rakefile +3 -1
  10. data/app/controllers/concerns/mysql_genius/ai_features.rb +90 -52
  11. data/app/controllers/concerns/mysql_genius/database_analysis.rb +73 -45
  12. data/app/controllers/concerns/mysql_genius/query_execution.rb +18 -16
  13. data/app/controllers/mysql_genius/base_controller.rb +3 -1
  14. data/app/controllers/mysql_genius/queries_controller.rb +19 -12
  15. data/app/services/mysql_genius/ai_client.rb +9 -2
  16. data/app/services/mysql_genius/ai_optimization_service.rb +8 -4
  17. data/app/services/mysql_genius/ai_suggestion_service.rb +5 -2
  18. data/app/views/layouts/mysql_genius/application.html.erb +141 -5
  19. data/app/views/mysql_genius/queries/_tab_dashboard.html.erb +95 -0
  20. data/app/views/mysql_genius/queries/_tab_duplicate_indexes.html.erb +11 -0
  21. data/app/views/mysql_genius/queries/_tab_query_explorer.html.erb +110 -0
  22. data/app/views/mysql_genius/queries/_tab_table_sizes.html.erb +6 -4
  23. data/app/views/mysql_genius/queries/_tab_unused_indexes.html.erb +11 -0
  24. data/app/views/mysql_genius/queries/index.html.erb +377 -52
  25. data/bin/console +1 -0
  26. data/config/routes.rb +2 -0
  27. data/docs/screenshots/dashboard.png +0 -0
  28. data/docs/screenshots/query_explore.png +0 -0
  29. data/docs/superpowers/plans/2026-04-08-dashboard-first-redesign.md +741 -0
  30. data/docs/superpowers/specs/2026-04-08-dashboard-first-redesign.md +87 -0
  31. data/lib/generators/mysql_genius/install/install_generator.rb +19 -0
  32. data/lib/generators/mysql_genius/install/templates/initializer.rb +56 -0
  33. data/lib/mysql_genius/configuration.rb +8 -6
  34. data/lib/mysql_genius/engine.rb +2 -0
  35. data/lib/mysql_genius/slow_query_monitor.rb +29 -25
  36. data/lib/mysql_genius/sql_validator.rb +6 -4
  37. data/lib/mysql_genius/version.rb +3 -1
  38. data/lib/mysql_genius.rb +2 -0
  39. data/mysql_genius.gemspec +9 -8
  40. metadata +19 -15
  41. data/app/views/mysql_genius/queries/_tab_sql_query.html.erb +0 -40
  42. data/app/views/mysql_genius/queries/_tab_visual_builder.html.erb +0 -61
  43. data/docs/screenshots/sql_query.png +0 -0
  44. data/docs/screenshots/visual_builder.png +0 -0
@@ -1,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
- [[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
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 json: { error: error }, status: :unprocessable_entity
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 json: {
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 json: { error: "Query exceeded the #{mysql_genius_config.query_timeout_ms / 1000} second timeout limit.", timeout: true }, status: :unprocessable_entity
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 json: { error: "Query error: #{e.message.split(':').last.strip}" }, status: :unprocessable_entity
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 json: { error: error }, status: :unprocessable_entity if error
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 json: { error: "This query appears to be truncated and cannot be explained." }, status: :unprocessable_entity
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 json: { columns: results.columns, rows: results.rows }
75
+ render(json: { columns: results.columns, rows: results.rows })
74
76
  rescue ActiveRecord::StatementInvalid => e
75
- render json: { error: "Explain error: #{e.message.split(':').last.strip}" }, status: :unprocessable_entity
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(/```/, "").strip
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 plain: "Not authorized", status: :unauthorized
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
- mysql_genius_config.featured_tables.sort
10
- else
11
- queryable_tables.sort
12
- end
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 json: { error: "Table '#{table}' is not available for querying." }, status: :forbidden
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 json: { error: "Table '#{table}' does not exist." }, status: :not_found
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 json: cols
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 json: { error: "Slow query monitoring is not configured." }, status: :not_found
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 { |entry| JSON.parse(entry) rescue nil }.compact
43
- render json: queries
44
- rescue => e
45
- render json: [], status: :ok
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['error']['message'] || parsed['error']}"
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(', ')}]#{idx.unique ? ' UNIQUE' : ''}" }
50
- desc = "#{table}: #{columns.join(', ')}"
51
- desc += "\n Indexes: #{indexes.join('; ')}" if indexes.any?
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,45 @@
77
78
 
78
79
  /* Table */
79
80
  .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; }
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; }
83
+ .mg-table th:first-child { border-top-left-radius: 6px; }
84
+ .mg-table th:last-child { border-top-right-radius: 6px; }
85
+ .mg-table td { padding: 8px 12px; border-bottom: 1px solid #eaecef; vertical-align: top; }
86
+ .mg-table tbody tr { transition: background-color 0.15s ease; }
87
+ .mg-table tbody tr:hover { background: #f0f6ff; }
88
+ .mg-table tbody tr:nth-child(even) { background: #fafbfc; }
89
+ .mg-table tbody tr:nth-child(even):hover { background: #f0f6ff; }
90
+ .mg-table tbody tr:last-child td { border-bottom: none; }
84
91
  .mg-table em.null { color: #999; font-style: italic; }
85
92
  .mg-table .redacted { color: #999; }
86
93
 
94
+ /* Numeric table cells */
95
+ .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; }
96
+
97
+ /* SQL code blocks in tables */
98
+ .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; }
99
+ .mg-sql-block::-webkit-scrollbar { width: 4px; }
100
+ .mg-sql-block::-webkit-scrollbar-track { background: transparent; }
101
+ .mg-sql-block::-webkit-scrollbar-thumb { background: #585b70; border-radius: 2px; }
102
+
103
+ /* SQL syntax colors (Catppuccin Mocha inspired) */
104
+ .mg-sql-kw { color: #cba6f7; font-weight: 600; }
105
+ .mg-sql-fn { color: #89b4fa; }
106
+ .mg-sql-str { color: #a6e3a1; }
107
+ .mg-sql-num { color: #fab387; }
108
+ .mg-sql-op { color: #89dceb; }
109
+ .mg-sql-comment { color: #6c7086; font-style: italic; }
110
+ .mg-sql-tbl { color: #f9e2af; }
111
+ .mg-sql-star { color: #f38ba8; font-weight: 700; }
112
+ .mg-sql-punc { color: #9399b2; }
113
+ .mg-sql-placeholder { color: #74c7ec; font-style: italic; }
114
+
115
+ /* Duration color coding */
116
+ .mg-dur-fast { color: #1a7f37; }
117
+ .mg-dur-moderate { color: #9a6700; }
118
+ .mg-dur-slow { color: #cf222e; font-weight: 600; }
119
+
87
120
  /* Checkbox grid */
88
121
  .mg-checks { display: flex; flex-wrap: wrap; gap: 4px 16px; }
89
122
  .mg-check { display: flex; align-items: center; gap: 4px; font-size: 13px; cursor: pointer; }
@@ -104,11 +137,114 @@
104
137
  .mg-mb { margin-bottom: 12px; }
105
138
  .mg-text-muted { color: #888; font-size: 13px; }
106
139
  .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; }
140
+ code { font-size: 12px; word-break: break-all; background: #f0f1f3; padding: 2px 6px; border-radius: 3px; color: #24292f; }
108
141
  pre.mg-pre { background: #f4f4f4; padding: 10px; border-radius: 4px; overflow-x: auto; font-size: 12px; }
142
+
143
+ /* Theme toggle */
144
+ .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; }
145
+ .mg-theme-toggle:hover { background: #e9ecef; }
146
+
147
+ /* --- Dark Theme --- */
148
+ [data-theme="dark"] body,
149
+ [data-theme="dark"] { background: #161b22; color: #c9d1d9; }
150
+ [data-theme="dark"] .mg-container { color: #c9d1d9; }
151
+
152
+ /* Tabs */
153
+ [data-theme="dark"] .mg-tabs { border-bottom-color: #30363d; }
154
+ [data-theme="dark"] .mg-tab { color: #8b949e; }
155
+ [data-theme="dark"] .mg-tab:hover { color: #c9d1d9; }
156
+ [data-theme="dark"] .mg-tab.active { background: #0d1117; border-color: #30363d; border-bottom-color: #0d1117; color: #c9d1d9; }
157
+ [data-theme="dark"] .mg-tab-content { background: #0d1117; border-color: #30363d; }
158
+
159
+ /* Forms */
160
+ [data-theme="dark"] select,
161
+ [data-theme="dark"] input[type="number"],
162
+ [data-theme="dark"] input[type="text"],
163
+ [data-theme="dark"] textarea { background: #0d1117; color: #c9d1d9; border-color: #30363d; }
164
+ [data-theme="dark"] select:focus,
165
+ [data-theme="dark"] input:focus,
166
+ [data-theme="dark"] textarea:focus { border-color: #58a6ff; box-shadow: 0 0 0 2px rgba(88,166,255,.15); }
167
+ [data-theme="dark"] textarea[readonly] { background: #161b22; }
168
+ [data-theme="dark"] label { color: #c9d1d9; }
169
+
170
+ /* Buttons */
171
+ [data-theme="dark"] .mg-btn-primary { background: #238636; border-color: #238636; }
172
+ [data-theme="dark"] .mg-btn-primary:hover:not(:disabled) { background: #2ea043; }
173
+ [data-theme="dark"] .mg-btn-outline { background: #0d1117; color: #58a6ff; border-color: #30363d; }
174
+ [data-theme="dark"] .mg-btn-outline:hover:not(:disabled) { background: #161b22; border-color: #58a6ff; }
175
+ [data-theme="dark"] .mg-btn-outline-secondary { background: #0d1117; color: #8b949e; border-color: #30363d; }
176
+ [data-theme="dark"] .mg-btn-outline-secondary:hover:not(:disabled) { background: #161b22; }
177
+ [data-theme="dark"] .mg-btn-outline-danger { background: #0d1117; color: #f85149; border-color: #30363d; }
178
+ [data-theme="dark"] .mg-btn-outline-danger:hover:not(:disabled) { background: #1c0d0d; }
179
+
180
+ /* Badges */
181
+ [data-theme="dark"] .mg-badge-info { background: #0d2a3a; color: #58a6ff; }
182
+ [data-theme="dark"] .mg-badge-warning { background: #2d2000; color: #d29922; }
183
+ [data-theme="dark"] .mg-badge-danger { background: #2d0d0d; color: #f85149; }
184
+ [data-theme="dark"] .mg-badge-secondary { background: #21262d; color: #8b949e; }
185
+ [data-theme="dark"] .mg-badge-success { background: #0d2d1a; color: #3fb950; }
186
+
187
+ /* Alerts */
188
+ [data-theme="dark"] .mg-alert-danger { background: #2d0d0d; color: #f85149; border-color: #3d1414; }
189
+ [data-theme="dark"] .mg-alert-warning { background: #2d2000; color: #d29922; border-color: #3d2e00; }
190
+ [data-theme="dark"] .mg-alert-info { background: #0d2a3a; color: #58a6ff; border-color: #0d3a5a; }
191
+
192
+ /* Cards */
193
+ [data-theme="dark"] .mg-card { border-color: #30363d; }
194
+ [data-theme="dark"] .mg-card-header { background: #161b22; border-bottom-color: #30363d; color: #c9d1d9; }
195
+ [data-theme="dark"] .mg-card-toggle { color: #58a6ff; }
196
+
197
+ /* Stat grid */
198
+ [data-theme="dark"] .mg-stat-grid .mg-stat-label { color: #8b949e; }
199
+ [data-theme="dark"] .mg-usage-bar { background: #21262d; }
200
+
201
+ /* Tables */
202
+ [data-theme="dark"] .mg-table th { background: #161b22; color: #8b949e; border-bottom-color: #30363d; }
203
+ [data-theme="dark"] .mg-table td { border-bottom-color: #21262d; }
204
+ [data-theme="dark"] .mg-table tbody tr:hover { background: #1c2128; }
205
+ [data-theme="dark"] .mg-table tbody tr:nth-child(even) { background: #0d1117; }
206
+ [data-theme="dark"] .mg-table tbody tr:nth-child(even):hover { background: #1c2128; }
207
+ [data-theme="dark"] .mg-table em.null { color: #484f58; }
208
+ [data-theme="dark"] .mg-table .redacted { color: #484f58; }
209
+
210
+ /* Duration colors (dark) */
211
+ [data-theme="dark"] .mg-dur-fast { color: #3fb950; }
212
+ [data-theme="dark"] .mg-dur-moderate { color: #d29922; }
213
+ [data-theme="dark"] .mg-dur-slow { color: #f85149; }
214
+
215
+ /* Inline code */
216
+ [data-theme="dark"] code { background: #21262d; color: #c9d1d9; }
217
+ [data-theme="dark"] pre.mg-pre { background: #161b22; color: #c9d1d9; }
218
+
219
+ /* Links */
220
+ [data-theme="dark"] .mg-link { color: #58a6ff; }
221
+
222
+ /* Spinner */
223
+ [data-theme="dark"] .mg-spinner { border-color: #30363d; border-top-color: #58a6ff; }
224
+
225
+ /* Text */
226
+ [data-theme="dark"] .mg-text-muted { color: #8b949e; }
227
+
228
+ /* Checkboxes */
229
+ [data-theme="dark"] .mg-check .type-hint { color: #484f58; }
230
+
231
+ /* Theme toggle (dark) */
232
+ [data-theme="dark"] .mg-theme-toggle { border-color: #30363d; color: #c9d1d9; }
233
+ [data-theme="dark"] .mg-theme-toggle:hover { background: #21262d; }
234
+
235
+ /* Database switcher (dark) */
236
+ [data-theme="dark"] .mg-db-switcher select { background: #0d1117; color: #c9d1d9; border-color: #30363d; }
237
+ [data-theme="dark"] .mg-db-badge { background: #0d2a3a; color: #58a6ff; }
109
238
  </style>
110
239
  </head>
111
240
  <body>
241
+ <script>
242
+ (function() {
243
+ var saved = localStorage.getItem('mg-theme');
244
+ var theme = saved || (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
245
+ document.documentElement.setAttribute('data-theme', theme);
246
+ })();
247
+ </script>
112
248
  <div class="mg-container">
113
249
  <%= yield %>
114
250
  </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 &rarr;</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 &rarr;</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 &rarr;</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">&#128203; 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>