mysql_genius-core 0.7.1 → 0.8.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: dfc2a8ccac7f377e556a0c5f51b843e97b4882f20a49b24ba41b5ab9e4623268
4
- data.tar.gz: 170b7fcfbdfab6e9aa20db0fef7dcbcf3d86018c8b41c35a0cbe927c3a8961c5
3
+ metadata.gz: 5075a444aed755238df78580fbd0e8ae79c2cb999ffc7504ff6af1ae8c08c8e7
4
+ data.tar.gz: 9edad6c0620a8c4434b716995ac7e0c2af52494c0f61912d45a1496468fa97e5
5
5
  SHA512:
6
- metadata.gz: 4111e15ebb74f4139c854b7710f745509939228e5b8f55013fec9b69b0ed40249453686bee6c17254205c0fe907ed544104fdc049971a618d7376ff122019388
7
- data.tar.gz: b2ddb49da235bfaac6ab6e75f9725d1f0162d6ded16ebf43dbd7e119d647c26cab3f12994213576636c5c8ee512722eb05ca3ee5ca9f229c594858b252051ff6
6
+ metadata.gz: bd2263afa33f3b61b826d5667ba01d319f3cddd39c8b44dad0e24a50dd06ff23b69224322dc8b6762491f1f10fbe4f9e9ccf199fb9899586e99506b07e652fbe
7
+ data.tar.gz: 60f654e26bf1aef5fadfe996caa743911c7303d227e79c67ce6e4a5e6a3bfd4828afa6ccbc58be79b763f391a81f9e7d8e4ecaa273f220b1f0454da493750415
data/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.8.0
4
+
5
+ ### Added
6
+ - `MysqlGenius::Core::Ai::VariableReviewer` — reviews MySQL config variables against best practices
7
+ - `MysqlGenius::Core::Ai::ConnectionAdvisor` — diagnoses connection pool health
8
+ - `MysqlGenius::Core::Ai::WorkloadDigest` — executive summary of the query workload
9
+ - `MysqlGenius::Core::Ai::InnodbInterpreter` — interprets `SHOW ENGINE INNODB STATUS` in plain English
10
+ - `MysqlGenius::Core::Ai::IndexPlanner` — holistic index optimization plan across tables
11
+ - `MysqlGenius::Core::Ai::PatternGrouper` — groups slow queries by shared root cause
12
+ - AI buttons in shared dashboard templates: Server tab (Variable Review, Connection Advisor, InnoDB Health), Query Stats tab (Workload Digest, Pattern Grouper), Unused Indexes tab (Index Planner)
13
+
14
+ ## 0.7.2
15
+
16
+ ### Added
17
+ - **Anthropic Messages API support** in `Core::Ai::Client` — detects `:x_api_key` auth style, sends `x-api-key` + `anthropic-version` headers, uses top-level `system` parameter and `content[0].text` response parsing.
18
+ - **`max_tokens` field** on `Core::Ai::Config` (default 4096) — sent in both OpenAI and Anthropic request bodies.
19
+ - **Copy response button** on all AI result sections in the shared dashboard template.
20
+ - **Dark mode CSS classes** for AI result sections (`mg-ai-section`, `mg-ai-danger`, `mg-ai-warning`, `mg-ai-info`) replacing hardcoded inline styles.
21
+ - **`capability?(:standalone_header)`** guard on the dashboard heading — hides it when the rendering adapter provides its own header.
22
+
3
23
  ## 0.7.1
4
24
 
5
25
  ### Fixed
@@ -27,12 +27,11 @@ module MysqlGenius
27
27
 
28
28
  raise NotConfigured, "AI is not configured" unless @config.enabled?
29
29
 
30
- body = {
31
- messages: messages,
32
- response_format: { type: "json_object" },
33
- temperature: temperature,
34
- }
35
- body[:model] = @config.model if @config.model && !@config.model.empty?
30
+ body = if anthropic?
31
+ build_anthropic_body(messages, temperature)
32
+ else
33
+ build_openai_body(messages, temperature)
34
+ end
36
35
 
37
36
  response = post_with_redirects(URI(@config.endpoint), body.to_json)
38
37
  parsed = JSON.parse(response.body)
@@ -41,7 +40,11 @@ module MysqlGenius
41
40
  raise ApiError, "AI API error: #{parsed["error"]["message"] || parsed["error"]}"
42
41
  end
43
42
 
44
- content = parsed.dig("choices", 0, "message", "content")
43
+ content = if anthropic?
44
+ parsed.dig("content", 0, "text")
45
+ else
46
+ parsed.dig("choices", 0, "message", "content")
47
+ end
45
48
  raise ApiError, "No content in AI response" if content.nil?
46
49
 
47
50
  parse_json_content(content)
@@ -49,6 +52,35 @@ module MysqlGenius
49
52
 
50
53
  private
51
54
 
55
+ def anthropic?
56
+ @config.auth_style == :x_api_key
57
+ end
58
+
59
+ def build_openai_body(messages, temperature)
60
+ body = {
61
+ messages: messages,
62
+ response_format: { type: "json_object" },
63
+ temperature: temperature,
64
+ }
65
+ body[:max_tokens] = @config.max_tokens.to_i if @config.max_tokens
66
+ body[:model] = @config.model if @config.model && !@config.model.empty?
67
+ body
68
+ end
69
+
70
+ def build_anthropic_body(messages, temperature)
71
+ system_text = messages.select { |m| m[:role] == "system" }.map { |m| m[:content] }.join("\n\n")
72
+ user_messages = messages.reject { |m| m[:role] == "system" }
73
+
74
+ body = {
75
+ messages: user_messages,
76
+ max_tokens: (@config.max_tokens || 4096).to_i,
77
+ temperature: temperature,
78
+ }
79
+ body[:system] = system_text unless system_text.empty?
80
+ body[:model] = @config.model if @config.model && !@config.model.empty?
81
+ body
82
+ end
83
+
52
84
  def parse_json_content(content)
53
85
  JSON.parse(content)
54
86
  rescue JSON::ParserError
@@ -78,8 +110,12 @@ module MysqlGenius
78
110
 
79
111
  request = Net::HTTP::Post.new(uri)
80
112
  request["Content-Type"] = "application/json"
81
- if @config.auth_style == :bearer
113
+ case @config.auth_style
114
+ when :bearer
82
115
  request["Authorization"] = "Bearer #{@config.api_key}"
116
+ when :x_api_key
117
+ request["x-api-key"] = @config.api_key
118
+ request["anthropic-version"] = "2023-06-01"
83
119
  else
84
120
  request["api-key"] = @config.api_key
85
121
  end
@@ -26,10 +26,11 @@ module MysqlGenius
26
26
  :auth_style,
27
27
  :system_context,
28
28
  :domain_context,
29
+ :max_tokens,
29
30
  keyword_init: true,
30
31
  ) do
31
32
  def initialize(**kwargs)
32
- super(domain_context: "", **kwargs)
33
+ super(domain_context: "", max_tokens: 4096, **kwargs)
33
34
  freeze
34
35
  end
35
36
 
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MysqlGenius
4
+ module Core
5
+ module Ai
6
+ # Diagnoses connection pool health by gathering connection-related
7
+ # metrics from SHOW GLOBAL STATUS and SHOW GLOBAL VARIABLES, then
8
+ # asking the LLM to distinguish between pool misconfiguration,
9
+ # connection leaks, missing pooling, and traffic saturation.
10
+ class ConnectionAdvisor
11
+ STATUS_KEYS = [
12
+ "Threads_connected",
13
+ "Threads_running",
14
+ "Max_used_connections",
15
+ "Aborted_connects",
16
+ "Aborted_clients",
17
+ "Connections",
18
+ "Threads_created",
19
+ ].freeze
20
+
21
+ VARIABLE_KEYS = [
22
+ "max_connections",
23
+ "wait_timeout",
24
+ "interactive_timeout",
25
+ "thread_cache_size",
26
+ ].freeze
27
+
28
+ def initialize(client, config, connection)
29
+ @client = client
30
+ @config = config
31
+ @connection = connection
32
+ end
33
+
34
+ def call
35
+ variables = fetch_variables
36
+ status = fetch_status
37
+
38
+ messages = [
39
+ { role: "system", content: system_prompt },
40
+ { role: "user", content: user_prompt(variables, status) },
41
+ ]
42
+ @client.chat(messages: messages)
43
+ end
44
+
45
+ private
46
+
47
+ def fetch_variables
48
+ result = @connection.exec_query("SHOW GLOBAL VARIABLES")
49
+ result.rows
50
+ .select { |row| VARIABLE_KEYS.include?(row[0]) }
51
+ .map { |row| [row[0], row[1]] }
52
+ end
53
+
54
+ def fetch_status
55
+ result = @connection.exec_query("SHOW GLOBAL STATUS")
56
+ result.rows
57
+ .select { |row| STATUS_KEYS.include?(row[0]) }
58
+ .map { |row| [row[0], row[1]] }
59
+ end
60
+
61
+ def system_prompt
62
+ <<~PROMPT
63
+ You are a MySQL connection health advisor. Analyze the connection-related variables and status counters below, then diagnose the connection health and provide specific recommendations. Consider:
64
+ - Connection utilization: Max_used_connections vs max_connections (target: stay below 80%)
65
+ - Aborted connections: Aborted_connects indicates authentication failures or client errors; Aborted_clients indicates clients disconnecting without proper cleanup
66
+ - Thread cache efficiency: Threads_created vs Connections (high ratio means thread_cache_size is too small)
67
+ - Timeout configuration: wait_timeout and interactive_timeout impact how long idle connections persist
68
+ - Connection leak indicators: high Threads_connected with low Threads_running suggests idle connection accumulation
69
+ - Traffic saturation: high Threads_running relative to CPU cores suggests query contention
70
+
71
+ Distinguish between these root causes:
72
+ 1. Pool misconfiguration (max_connections too low/high, bad timeout values)
73
+ 2. Connection leaks (growing Threads_connected, high Aborted_clients)
74
+ 3. Missing connection pooling (high Connections with short-lived threads)
75
+ 4. Traffic saturation (high Threads_running, query contention)
76
+ #{@config.domain_context}
77
+ Respond with JSON: {"diagnosis": "markdown analysis distinguishing between pool misconfiguration, connection leaks, missing pooling, and traffic saturation, with specific variable recommendations and values"}
78
+ PROMPT
79
+ end
80
+
81
+ def user_prompt(variables, status)
82
+ lines = ["== Connection Variables =="]
83
+ variables.each { |name, value| lines << "#{name} = #{value}" }
84
+ lines << ""
85
+ lines << "== Connection Status Counters =="
86
+ status.each { |name, value| lines << "#{name} = #{value}" }
87
+ lines.join("\n")
88
+ end
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MysqlGenius
4
+ module Core
5
+ module Ai
6
+ class IndexPlanner
7
+ def initialize(client, config, connection)
8
+ @client = client
9
+ @config = config
10
+ @connection = connection
11
+ end
12
+
13
+ def call(tables = nil)
14
+ target_tables = resolve_tables(tables)
15
+ return { "plan" => "No tables found to analyze." } if target_tables.empty?
16
+
17
+ unused = Analysis::UnusedIndexes.new(@connection).call
18
+ duplicates = Analysis::DuplicateIndexes.new(@connection, blocked_tables: []).call
19
+ schema = SchemaContextBuilder.new(@connection).call(target_tables, detail: :with_cardinality)
20
+
21
+ index_map = target_tables.to_h do |table|
22
+ [table, @connection.indexes_for(table).map do |idx|
23
+ "#{"UNIQUE " if idx.unique}INDEX #{idx.name} (#{idx.columns.join(", ")})"
24
+ end,]
25
+ end
26
+
27
+ messages = [
28
+ { role: "system", content: system_prompt },
29
+ { role: "user", content: user_prompt(schema, unused, duplicates, index_map) },
30
+ ]
31
+ @client.chat(messages: messages)
32
+ end
33
+
34
+ private
35
+
36
+ def resolve_tables(tables)
37
+ list = Array(tables).reject { |t| t.to_s.empty? }
38
+ return list unless list.empty?
39
+
40
+ top_tables_by_size
41
+ end
42
+
43
+ def top_tables_by_size
44
+ db = @connection.current_database
45
+ result = @connection.exec_query(
46
+ "SELECT table_name FROM information_schema.tables " \
47
+ "WHERE table_schema = #{@connection.quote(db)} AND table_type = 'BASE TABLE' " \
48
+ "ORDER BY (data_length + index_length) DESC LIMIT 10",
49
+ )
50
+ result.rows.map(&:first)
51
+ rescue StandardError
52
+ @connection.tables.first(10)
53
+ end
54
+
55
+ def user_prompt(schema, unused, duplicates, index_map)
56
+ parts = ["Schema with cardinality:\n#{schema}"]
57
+
58
+ if unused.any?
59
+ unused_text = unused.map { |u| "#{u[:table]}.#{u[:index_name]} (reads=#{u[:reads]}, writes=#{u[:writes]})" }
60
+ parts << "Unused indexes (zero reads):\n#{unused_text.join("\n")}"
61
+ end
62
+
63
+ if duplicates.any?
64
+ dup_text = duplicates.map do |d|
65
+ "#{d[:table]}: #{d[:duplicate_index]} (#{d[:duplicate_columns].join(", ")}) covered by #{d[:covered_by_index]} (#{d[:covered_by_columns].join(", ")})"
66
+ end
67
+ parts << "Duplicate indexes:\n#{dup_text.join("\n")}"
68
+ end
69
+
70
+ index_summary = index_map.map { |table, idxs| "#{table}: #{idxs.any? ? idxs.join("; ") : "NONE"}" }
71
+ parts << "Current indexes per table:\n#{index_summary.join("\n")}"
72
+
73
+ parts.join("\n\n")
74
+ end
75
+
76
+ def system_prompt
77
+ <<~PROMPT
78
+ You are a MySQL index consolidation planner. Given schema information, unused indexes, duplicate indexes, and current index listings, produce a consolidated optimization plan. For each recommendation:
79
+ - DROP redundant or unused indexes (with exact ALTER TABLE ... DROP INDEX statements)
80
+ - MERGE overlapping indexes into composites where beneficial
81
+ - KEEP indexes that are actively used and well-structured
82
+ - ADD new composite indexes where query patterns suggest benefit
83
+ - Provide rationale for each change and estimated impact on read/write performance
84
+ #{@config.domain_context}
85
+ Respond with JSON: {"plan": "markdown with specific ALTER TABLE / DROP INDEX / CREATE INDEX statements, rationale for each change, and estimated impact"}
86
+ PROMPT
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MysqlGenius
4
+ module Core
5
+ module Ai
6
+ # Interprets SHOW ENGINE INNODB STATUS output in plain English.
7
+ # Combines the raw InnoDB status text with key metrics from
8
+ # ServerOverview to give the LLM full context for its analysis.
9
+ class InnodbInterpreter
10
+ MAX_STATUS_LENGTH = 4000
11
+
12
+ def initialize(client, config, connection)
13
+ @client = client
14
+ @config = config
15
+ @connection = connection
16
+ end
17
+
18
+ def call
19
+ status_text = fetch_innodb_status
20
+ metrics = fetch_innodb_metrics
21
+
22
+ messages = [
23
+ { role: "system", content: system_prompt },
24
+ { role: "user", content: user_prompt(status_text, metrics) },
25
+ ]
26
+ @client.chat(messages: messages)
27
+ end
28
+
29
+ private
30
+
31
+ def fetch_innodb_status
32
+ result = @connection.exec_query("SHOW ENGINE INNODB STATUS")
33
+ text = result.rows.first&.dig(2).to_s
34
+ text.length > MAX_STATUS_LENGTH ? text[0, MAX_STATUS_LENGTH] : text
35
+ end
36
+
37
+ def fetch_innodb_metrics
38
+ overview = Analysis::ServerOverview.new(@connection).call
39
+ overview[:innodb]
40
+ end
41
+
42
+ def system_prompt
43
+ <<~PROMPT
44
+ You are a MySQL InnoDB internals expert. Analyze the SHOW ENGINE INNODB STATUS output and the supplementary metrics below. Provide a plain-English interpretation organized by these sections:
45
+ - **Deadlocks**: recent deadlock information, lock wait chains, affected transactions
46
+ - **Transaction History**: history list length, purge lag, long-running transactions
47
+ - **Buffer Pool**: hit rate, dirty page ratio, free pages, eviction pressure
48
+ - **I/O**: pending reads/writes, log sequence numbers, checkpoint age, log sizing adequacy
49
+ - **Semaphores**: mutex/rw-lock waits, spin rounds, OS waits indicating contention
50
+
51
+ For each section, explain what the numbers mean in practical terms and recommend specific actions if problems are detected.
52
+ #{@config.domain_context}
53
+ Respond with JSON: {"findings": "markdown analysis organized by: Deadlocks, Transaction History, Buffer Pool, I/O, Semaphores. Each section should include current state assessment, risk level, and actionable recommendations."}
54
+ PROMPT
55
+ end
56
+
57
+ def user_prompt(status_text, metrics)
58
+ lines = ["== SHOW ENGINE INNODB STATUS =="]
59
+ lines << status_text
60
+ lines << ""
61
+ lines << "== InnoDB Metrics Summary =="
62
+ lines << "Buffer Pool Size: #{metrics[:buffer_pool_mb]} MB"
63
+ lines << "Buffer Pool Hit Rate: #{metrics[:buffer_pool_hit_rate]}%"
64
+ lines << "Dirty Pages: #{metrics[:buffer_pool_pages_dirty]}"
65
+ lines << "Free Pages: #{metrics[:buffer_pool_pages_free]}"
66
+ lines << "Total Pages: #{metrics[:buffer_pool_pages_total]}"
67
+ lines << "Row Lock Waits: #{metrics[:row_lock_waits]}"
68
+ lines << "Row Lock Time (ms): #{metrics[:row_lock_time_ms]}"
69
+ lines.join("\n")
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MysqlGenius
4
+ module Core
5
+ module Ai
6
+ # Groups slow queries by shared root cause so a single fix can improve
7
+ # multiple queries at once. Pulls high-cost statements from
8
+ # performance_schema, extracts referenced tables, builds schema context,
9
+ # and asks the LLM to cluster queries by underlying issue.
10
+ #
11
+ # Construct with:
12
+ # connection - a Core::Connection implementation
13
+ # client - a Core::Ai::Client
14
+ # config - the Core::Ai::Config
15
+ #
16
+ # Call:
17
+ # .call() -> Hash with "groups" key containing markdown analysis
18
+ class PatternGrouper
19
+ QUERY_LIMIT = 30
20
+ ROWS_RATIO_THRESHOLD = 10
21
+ AVG_TIME_THRESHOLD = 50
22
+
23
+ def initialize(connection, client, config)
24
+ @connection = connection
25
+ @client = client
26
+ @config = config
27
+ end
28
+
29
+ def call
30
+ all_stats = Analysis::QueryStats.new(@connection).call(sort: "total_time", limit: QUERY_LIMIT)
31
+ high_cost = all_stats.select { |s| s[:rows_ratio] > ROWS_RATIO_THRESHOLD || s[:avg_time_ms] > AVG_TIME_THRESHOLD }
32
+ return { "groups" => "No high-cost queries found to analyze." } if high_cost.empty?
33
+
34
+ tables = extract_tables(high_cost)
35
+ schema = tables.any? ? SchemaContextBuilder.new(@connection).call(tables, detail: :basic) : ""
36
+
37
+ messages = [
38
+ { role: "system", content: system_prompt },
39
+ { role: "user", content: user_prompt(high_cost, schema) },
40
+ ]
41
+
42
+ @client.chat(messages: messages)
43
+ end
44
+
45
+ private
46
+
47
+ def extract_tables(stats)
48
+ stats.flat_map { |s| SqlValidator.extract_table_references(s[:sql], @connection) }.uniq
49
+ end
50
+
51
+ def system_prompt
52
+ prompt = <<~PROMPT
53
+ You are a MySQL performance analyst specializing in root-cause analysis.
54
+ PROMPT
55
+
56
+ if @config.domain_context && !@config.domain_context.empty?
57
+ prompt += <<~PROMPT
58
+
59
+ Domain context:
60
+ #{@config.domain_context}
61
+ PROMPT
62
+ end
63
+
64
+ prompt += <<~PROMPT
65
+
66
+ Given a set of high-cost queries and the schema they reference, group them by shared root cause.
67
+ For each group provide:
68
+ 1. The shared root cause (e.g., missing index, full table scan, implicit type conversion)
69
+ 2. The affected queries (numbered)
70
+ 3. A single fix that addresses all queries in the group (with exact SQL: CREATE INDEX, ALTER TABLE, etc.)
71
+ 4. Estimated performance impact
72
+
73
+ Respond with JSON: {"groups": "markdown with each group showing: the shared root cause, affected queries (numbered), the single fix that addresses all of them (with exact SQL), and estimated impact"}
74
+ PROMPT
75
+
76
+ prompt
77
+ end
78
+
79
+ def user_prompt(stats, schema)
80
+ formatted = stats.map.with_index(1) do |s, i|
81
+ "#{i}. SQL: #{s[:sql]}\n " \
82
+ "calls=#{s[:calls]}, avg_time_ms=#{s[:avg_time_ms]}, " \
83
+ "rows_ratio=#{s[:rows_ratio]}, rows_examined=#{s[:rows_examined]}, " \
84
+ "tmp_disk_tables=#{s[:tmp_disk_tables]}"
85
+ end.join("\n")
86
+
87
+ parts = ["High-cost queries (rows_ratio > #{ROWS_RATIO_THRESHOLD} OR avg_time_ms > #{AVG_TIME_THRESHOLD}):\n\n#{formatted}"]
88
+ parts << "Schema context:\n#{schema}" unless schema.empty?
89
+ parts.join("\n\n")
90
+ end
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MysqlGenius
4
+ module Core
5
+ module Ai
6
+ # Reviews MySQL configuration variables against best practices for
7
+ # the observed workload. Gathers SHOW GLOBAL VARIABLES (filtered to
8
+ # ~20 performance-relevant keys) and SHOW GLOBAL STATUS, then asks
9
+ # the LLM to identify misconfigurations.
10
+ class VariableReviewer
11
+ RELEVANT_VARIABLES = [
12
+ "innodb_buffer_pool_size",
13
+ "innodb_log_file_size",
14
+ "innodb_flush_log_at_trx_commit",
15
+ "max_connections",
16
+ "query_cache_type",
17
+ "sort_buffer_size",
18
+ "join_buffer_size",
19
+ "tmp_table_size",
20
+ "max_heap_table_size",
21
+ "thread_cache_size",
22
+ "table_open_cache",
23
+ "innodb_file_per_table",
24
+ "innodb_flush_method",
25
+ "binlog_format",
26
+ "sync_binlog",
27
+ "innodb_io_capacity",
28
+ "innodb_read_io_threads",
29
+ "innodb_write_io_threads",
30
+ "long_query_time",
31
+ "slow_query_log",
32
+ "performance_schema",
33
+ ].freeze
34
+
35
+ RELEVANT_STATUS_KEYS = [
36
+ "Innodb_buffer_pool_reads",
37
+ "Innodb_buffer_pool_read_requests",
38
+ "Created_tmp_disk_tables",
39
+ "Created_tmp_tables",
40
+ "Sort_merge_passes",
41
+ "Threads_created",
42
+ "Threads_connected",
43
+ "Max_used_connections",
44
+ "Slow_queries",
45
+ "Questions",
46
+ "Uptime",
47
+ ].freeze
48
+
49
+ def initialize(client, config, connection)
50
+ @client = client
51
+ @config = config
52
+ @connection = connection
53
+ end
54
+
55
+ def call
56
+ variables = fetch_variables
57
+ status = fetch_status
58
+
59
+ messages = [
60
+ { role: "system", content: system_prompt },
61
+ { role: "user", content: user_prompt(variables, status) },
62
+ ]
63
+ @client.chat(messages: messages)
64
+ end
65
+
66
+ private
67
+
68
+ def fetch_variables
69
+ result = @connection.exec_query("SHOW GLOBAL VARIABLES")
70
+ result.rows
71
+ .select { |row| RELEVANT_VARIABLES.include?(row[0]) }
72
+ .map { |row| [row[0], row[1]] }
73
+ end
74
+
75
+ def fetch_status
76
+ result = @connection.exec_query("SHOW GLOBAL STATUS")
77
+ result.rows
78
+ .select { |row| RELEVANT_STATUS_KEYS.include?(row[0]) }
79
+ .map { |row| [row[0], row[1]] }
80
+ end
81
+
82
+ def system_prompt
83
+ <<~PROMPT
84
+ You are a MySQL configuration reviewer. Analyze the server variables and status counters below, then identify misconfigurations and improvement opportunities. Consider:
85
+ - Buffer pool sizing relative to workload (hit rate from status counters)
86
+ - Temporary table spills to disk (tmp_table_size vs Created_tmp_disk_tables)
87
+ - Sort buffer and join buffer sizing
88
+ - Connection pool sizing (max_connections vs Max_used_connections)
89
+ - Thread cache effectiveness
90
+ - InnoDB flush and sync settings for durability vs performance trade-offs
91
+ - Slow query log configuration
92
+ - Binary log format suitability
93
+ #{@config.domain_context}
94
+ Respond with JSON: {"findings": "markdown-formatted analysis organized by severity (Critical, Warning, Suggestion). Include specific SET GLOBAL or my.cnf recommendations with before/after values."}
95
+ PROMPT
96
+ end
97
+
98
+ def user_prompt(variables, status)
99
+ lines = ["== Server Variables =="]
100
+ variables.each { |name, value| lines << "#{name} = #{value}" }
101
+ lines << ""
102
+ lines << "== Server Status Counters =="
103
+ status.each { |name, value| lines << "#{name} = #{value}" }
104
+ lines.join("\n")
105
+ end
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MysqlGenius
4
+ module Core
5
+ module Ai
6
+ # Produces a high-level executive summary of the query workload by
7
+ # pulling the top statements from performance_schema and asking the
8
+ # LLM to characterize read/write ratio, access patterns, waste
9
+ # concentration, and highest-leverage optimization opportunities.
10
+ #
11
+ # Construct with:
12
+ # connection - a Core::Connection implementation
13
+ # client - a Core::Ai::Client
14
+ # config - the Core::Ai::Config
15
+ #
16
+ # Call:
17
+ # .call() -> Hash with "digest" key containing markdown analysis
18
+ class WorkloadDigest
19
+ TOP_N = 30
20
+
21
+ def initialize(connection, client, config)
22
+ @connection = connection
23
+ @client = client
24
+ @config = config
25
+ end
26
+
27
+ def call
28
+ stats = Analysis::QueryStats.new(@connection).call(sort: "total_time", limit: TOP_N)
29
+ formatted = format_stats(stats)
30
+
31
+ messages = [
32
+ { role: "system", content: system_prompt },
33
+ { role: "user", content: user_prompt(formatted, stats.length) },
34
+ ]
35
+
36
+ @client.chat(messages: messages)
37
+ end
38
+
39
+ private
40
+
41
+ def system_prompt
42
+ prompt = <<~PROMPT
43
+ You are a MySQL performance analyst producing an executive workload digest.
44
+ PROMPT
45
+
46
+ if @config.domain_context && !@config.domain_context.empty?
47
+ prompt += <<~PROMPT
48
+
49
+ Domain context:
50
+ #{@config.domain_context}
51
+ PROMPT
52
+ end
53
+
54
+ prompt += <<~PROMPT
55
+
56
+ Analyze the provided query workload data and produce a concise executive summary covering:
57
+ 1. Read vs write ratio and overall workload characterization
58
+ 2. Access patterns (point lookups, range scans, full table scans, aggregations)
59
+ 3. Waste concentration — which queries examine many rows but return few
60
+ 4. Top 3 highest-leverage changes that would improve overall performance
61
+
62
+ Respond with JSON: {"digest": "markdown-formatted workload analysis"}
63
+ PROMPT
64
+
65
+ prompt
66
+ end
67
+
68
+ def user_prompt(formatted_stats, count)
69
+ <<~PROMPT
70
+ Top #{count} queries by total execution time:
71
+
72
+ #{formatted_stats}
73
+ PROMPT
74
+ end
75
+
76
+ def format_stats(stats)
77
+ stats.map.with_index(1) do |s, i|
78
+ "#{i}. SQL: #{s[:sql]}\n " \
79
+ "calls=#{s[:calls]}, avg_time_ms=#{s[:avg_time_ms]}, " \
80
+ "rows_ratio=#{s[:rows_ratio]}, tmp_disk_tables=#{s[:tmp_disk_tables]}"
81
+ end.join("\n")
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module MysqlGenius
4
4
  module Core
5
- VERSION = "0.7.1"
5
+ VERSION = "0.8.0"
6
6
  end
7
7
  end
@@ -26,13 +26,16 @@
26
26
  </div>
27
27
 
28
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>
29
+ <div id="query-results" class="mg-mt mg-hidden">
30
+ <div style="display:flex;justify-content:space-between;align-items:center;">
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
+ <button id="results-close" class="mg-btn mg-btn-outline-secondary mg-btn-sm" style="margin-bottom:12px;">&#10005; Dismiss</button>
35
37
  </div>
38
+ <div id="results-alert" class="mg-hidden"></div>
36
39
  <div id="results-table-wrapper" class="mg-table-wrap mg-hidden">
37
40
  <table class="mg-table">
38
41
  <thead id="results-thead"></thead>
@@ -2,6 +2,23 @@
2
2
  <div class="mg-tab-content" id="tab-qstats">
3
3
  <div class="mg-row" style="justify-content:space-between;align-items:center;margin-bottom:12px;">
4
4
  <div class="mg-text-muted">Top queries from <code>performance_schema.events_statements_summary_by_digest</code>. <span id="qstats-count" class="mg-badge mg-badge-secondary"></span></div>
5
+ <div style="display:flex;gap:4px;">
6
+ <% if @ai_enabled %>
7
+ <button id="qstats-workload-digest" class="mg-btn mg-btn-outline mg-btn-sm">&#9889; Workload Digest</button>
8
+ <button id="qstats-pattern-grouper" class="mg-btn mg-btn-outline mg-btn-sm">&#9889; Pattern Grouper</button>
9
+ <% end %>
10
+ </div>
11
+ </div>
12
+ <div id="qstats-ai-result" class="mg-mt mg-hidden" style="margin-bottom:12px;">
13
+ <div class="mg-card">
14
+ <div class="mg-card-header">
15
+ <span id="qstats-ai-title"><strong>&#9889; AI Analysis</strong></span>
16
+ <button id="qstats-ai-close" class="mg-btn mg-btn-outline-secondary mg-btn-sm">&#10005;</button>
17
+ </div>
18
+ <div class="mg-card-body">
19
+ <div id="qstats-ai-content" style="font-size:13px;"></div>
20
+ </div>
21
+ </div>
5
22
  </div>
6
23
  <div id="qstats-loading" class="mg-text-center mg-hidden"><span class="mg-spinner"></span> Loading...</div>
7
24
  <div id="qstats-error" class="mg-hidden"></div>
@@ -1,10 +1,15 @@
1
1
  <!-- Server Tab -->
2
2
  <div class="mg-tab-content" id="tab-server">
3
- <div class="mg-row" style="justify-content:flex-end;margin-bottom:12px;">
3
+ <div class="mg-row" style="justify-content:flex-end;margin-bottom:12px;flex-wrap:wrap;gap:4px;">
4
4
  <% if @ai_enabled && capability?(:slow_queries) %>
5
5
  <button id="server-root-cause" class="mg-btn mg-btn-primary mg-btn-sm">&#9889; Why is it slow?</button>
6
6
  <button id="server-anomaly" class="mg-btn mg-btn-outline mg-btn-sm">&#9889; Anomaly Detection</button>
7
7
  <% end %>
8
+ <% if @ai_enabled %>
9
+ <button id="server-variable-review" class="mg-btn mg-btn-outline mg-btn-sm">&#9889; Variable Review</button>
10
+ <button id="server-connection-advisor" class="mg-btn mg-btn-outline mg-btn-sm">&#9889; Connection Advisor</button>
11
+ <button id="server-innodb-health" class="mg-btn mg-btn-outline mg-btn-sm">&#9889; InnoDB Health</button>
12
+ <% end %>
8
13
  <button id="server-refresh" class="mg-btn mg-btn-outline-secondary mg-btn-sm">&#8635; Refresh</button>
9
14
  </div>
10
15
  <div id="server-loading" class="mg-text-center mg-hidden"><span class="mg-spinner"></span> Loading...</div>
@@ -2,7 +2,23 @@
2
2
  <div class="mg-tab-content" id="tab-unused">
3
3
  <div class="mg-row" style="justify-content:space-between;align-items:center;margin-bottom:12px;">
4
4
  <div class="mg-text-muted">Indexes with zero reads since last server restart, from <code>performance_schema</code>. <span id="unused-count" class="mg-badge mg-badge-secondary"></span></div>
5
- <button id="unused-refresh" class="mg-btn mg-btn-outline-secondary mg-btn-sm">&#8635; Refresh</button>
5
+ <div style="display:flex;gap:4px;">
6
+ <% if @ai_enabled %>
7
+ <button id="unused-index-planner" class="mg-btn mg-btn-outline mg-btn-sm">&#9889; Index Planner</button>
8
+ <% end %>
9
+ <button id="unused-refresh" class="mg-btn mg-btn-outline-secondary mg-btn-sm">&#8635; Refresh</button>
10
+ </div>
11
+ </div>
12
+ <div id="unused-ai-result" class="mg-mt mg-hidden" style="margin-bottom:12px;">
13
+ <div class="mg-card">
14
+ <div class="mg-card-header">
15
+ <span id="unused-ai-title"><strong>&#9889; Index Planner</strong></span>
16
+ <button id="unused-ai-close" class="mg-btn mg-btn-outline-secondary mg-btn-sm">&#10005;</button>
17
+ </div>
18
+ <div class="mg-card-body">
19
+ <div id="unused-ai-content" style="font-size:13px;"></div>
20
+ </div>
21
+ </div>
6
22
  </div>
7
23
  <div id="unused-loading" class="mg-text-center mg-hidden"><span class="mg-spinner"></span> Scanning...</div>
8
24
  <div id="unused-error" class="mg-hidden"></div>
@@ -1,9 +1,11 @@
1
+ <% if capability?(:standalone_header) %>
1
2
  <div style="display:flex;align-items:center;justify-content:space-between;">
2
3
  <h4>&#128024; MySQLGenius</h4>
3
4
  <button class="mg-theme-toggle" id="mg-theme-btn" title="Toggle dark/light theme" onclick="(function(){var d=document.documentElement,t=d.getAttribute('data-theme')==='dark'?'light':'dark';d.setAttribute('data-theme',t);localStorage.setItem('mg-theme',t);document.getElementById('mg-theme-btn').textContent=t==='dark'?'\u2600\uFE0F':'\uD83C\uDF19';})()">
4
5
  <script>document.write(document.documentElement.getAttribute('data-theme')==='dark'?'\u2600\uFE0F':'\uD83C\uDF19')</script>
5
6
  </button>
6
7
  </div>
8
+ <% end %>
7
9
 
8
10
  <div class="mg-tabs">
9
11
  <button class="mg-tab active" data-tab="dashboard">Dashboard</button>
@@ -60,6 +62,12 @@
60
62
  anomaly_detection: '<%= path_for(:anomaly_detection) %>',
61
63
  root_cause: '<%= path_for(:root_cause) %>',
62
64
  migration_risk: '<%= path_for(:migration_risk) %>',
65
+ variable_review: '<%= path_for(:variable_review) %>',
66
+ connection_advisor: '<%= path_for(:connection_advisor) %>',
67
+ workload_digest: '<%= path_for(:workload_digest) %>',
68
+ innodb_health: '<%= path_for(:innodb_health) %>',
69
+ index_planner: '<%= path_for(:index_planner) %>',
70
+ pattern_grouper: '<%= path_for(:pattern_grouper) %>',
63
71
  query_detail: '/queries/'
64
72
  };
65
73
 
@@ -299,6 +307,13 @@
299
307
  btn.classList.add('active');
300
308
  el('tab-' + name).classList.add('active');
301
309
  history.replaceState(null, '', location.pathname + '#' + name);
310
+
311
+ // Hide query results and AI responses when leaving the explorer tab
312
+ if (name !== 'explorer') {
313
+ hide(el('query-results'));
314
+ hide(el('explain-results'));
315
+ hide(el('ai-query-result'));
316
+ }
302
317
  if (name === 'dashboard') loadDashboard();
303
318
  if (name === 'slow') loadSlowQueries();
304
319
  if (name === 'indexes') loadDuplicateIndexes();
@@ -705,6 +720,7 @@
705
720
  }, function(json) {
706
721
  var cls = (json && json.timeout) ? 'mg-alert-warning' : 'mg-alert-danger';
707
722
  el('results-alert').innerHTML = '<div class="mg-alert ' + cls + '">' + escHtml((json && json.error) || 'An unexpected error occurred.') + '</div>';
723
+ show(el('query-results'));
708
724
  show(el('results-alert'));
709
725
  setBtnLoading(['vb-run', 'sql-run'], false);
710
726
  });
@@ -716,9 +732,11 @@
716
732
  hide(el('results-truncated'));
717
733
  el('results-thead').innerHTML = '';
718
734
  el('results-tbody').innerHTML = '';
735
+ show(el('query-results'));
719
736
  }
720
737
 
721
738
  function renderResults(data) {
739
+ show(el('query-results'));
722
740
  if (data.row_count === 0) {
723
741
  show(el('results-empty')); show(el('results-stats'));
724
742
  el('results-row-count').textContent = '0 rows';
@@ -746,6 +764,7 @@
746
764
  el('vb-explain').addEventListener('click', function() { var sql = buildSql(); if (sql) runExplain(sql); });
747
765
  el('sql-explain').addEventListener('click', function() { var sql = el('sql-input').value.trim(); if (sql) runExplain(sql); });
748
766
  el('explain-close').addEventListener('click', function() { hide(el('explain-results')); });
767
+ el('results-close').addEventListener('click', function() { hide(el('query-results')); });
749
768
 
750
769
  function runExplain(sql, fromSlowQuery) {
751
770
  lastExplainSql = sql;
@@ -825,7 +844,7 @@
825
844
  });
826
845
 
827
846
  ajax('POST', ROUTES.optimize, data, function(resp) {
828
- el('optimize-content').innerHTML = formatMarkdown(resp.suggestions || 'No suggestions available.');
847
+ el('optimize-content').innerHTML = formatMarkdown(resp.suggestions || 'No suggestions available.') + copyButton('optimize-content');
829
848
  show(el('optimize-results'));
830
849
  explainOptimize.disabled = false;
831
850
  explainOptimize.innerHTML = '&#9889; AI Optimization';
@@ -1031,7 +1050,9 @@
1031
1050
  '<td class="mg-num"><strong>' + formatMb(t.total_mb) + '</strong></td>' +
1032
1051
  '<td class="mg-num">' + fragText + '</td>' +
1033
1052
  '<td>' + updated + '</td>' +
1034
- '<td>' + sizeBar(pct, color) + '</td>' +
1053
+ '<td>' + sizeBar(pct, color) +
1054
+ (t.needs_optimize ? ' <button class="mg-btn mg-btn-outline mg-btn-sm mg-table-optimize" data-table="' + escHtml(t.table) + '" style="margin-left:4px;">&#9889; AI Optimize</button>' : '') +
1055
+ '</td>' +
1035
1056
  '</tr>';
1036
1057
  }).join('');
1037
1058
  show(el('sizes-table-wrapper'));
@@ -1044,6 +1065,24 @@
1044
1065
 
1045
1066
  el('sizes-refresh').addEventListener('click', loadTableSizes);
1046
1067
 
1068
+ // AI Optimize button on tables with fragmentation
1069
+ document.addEventListener('click', function(e) {
1070
+ var btn = e.target.closest('.mg-table-optimize');
1071
+ if (!btn) return;
1072
+ var table = btn.dataset.table;
1073
+ btn.disabled = true;
1074
+ btn.innerHTML = '<span class="mg-spinner"></span>';
1075
+ aiCall(ROUTES.schema_review, { table: table }, function(data) {
1076
+ showAiQueryResult('Optimization: ' + table, formatFindings(data.findings || data.raw || 'No suggestions.'));
1077
+ btn.disabled = false;
1078
+ btn.innerHTML = '&#9889; AI Optimize';
1079
+ }, function(err) {
1080
+ showAiQueryResult('Error', '<div class="mg-alert mg-alert-danger">' + escHtml(err) + '</div>');
1081
+ btn.disabled = false;
1082
+ btn.innerHTML = '&#9889; AI Optimize';
1083
+ });
1084
+ });
1085
+
1047
1086
  // --- Query Stats ---
1048
1087
 
1049
1088
  function loadQueryStats() {
@@ -1230,9 +1269,25 @@
1230
1269
  });
1231
1270
  }
1232
1271
 
1272
+ function copyButton(targetId) {
1273
+ return '<div style="text-align:right;margin-top:8px;"><button class="mg-btn mg-btn-outline-secondary mg-btn-sm mg-copy-ai" data-target="' + targetId + '">Copy response</button></div>';
1274
+ }
1275
+
1276
+ document.addEventListener('click', function(e) {
1277
+ var btn = e.target.closest('.mg-copy-ai');
1278
+ if (!btn) return;
1279
+ var target = el(btn.dataset.target);
1280
+ if (!target) return;
1281
+ var text = target.innerText || target.textContent;
1282
+ navigator.clipboard.writeText(text).then(function() {
1283
+ btn.textContent = 'Copied!';
1284
+ setTimeout(function() { btn.textContent = 'Copy response'; }, 2000);
1285
+ });
1286
+ });
1287
+
1233
1288
  function showAiQueryResult(title, html) {
1234
1289
  el('ai-query-title').innerHTML = '<strong>&#9889; ' + escHtml(title) + '</strong>';
1235
- el('ai-query-content').innerHTML = html;
1290
+ el('ai-query-content').innerHTML = html + copyButton('ai-query-content');
1236
1291
  show(el('ai-query-result'));
1237
1292
  }
1238
1293
 
@@ -1306,7 +1361,7 @@
1306
1361
  });
1307
1362
 
1308
1363
  aiCall(ROUTES.index_advisor, data, function(resp) {
1309
- el('optimize-content').innerHTML = formatMarkdown(resp.indexes || resp.raw || 'No suggestions.');
1364
+ el('optimize-content').innerHTML = formatMarkdown(resp.indexes || resp.raw || 'No suggestions.') + copyButton('optimize-content');
1310
1365
  show(el('optimize-results'));
1311
1366
  indexAdvisor.disabled = false;
1312
1367
  indexAdvisor.innerHTML = '&#9889; Index Advisor';
@@ -1329,7 +1384,7 @@
1329
1384
  hide(el('server-ai-result'));
1330
1385
  aiCall(ROUTES.root_cause, {}, function(data) {
1331
1386
  el('server-ai-title').innerHTML = '<strong>&#9889; Root Cause Analysis</strong>';
1332
- el('server-ai-content').innerHTML = formatMarkdown(data.diagnosis || data.raw || 'No diagnosis.');
1387
+ el('server-ai-content').innerHTML = formatMarkdown(data.diagnosis || data.raw || 'No diagnosis.') + copyButton('server-ai-content');
1333
1388
  show(el('server-ai-result'));
1334
1389
  rootCauseBtn.disabled = false;
1335
1390
  rootCauseBtn.innerHTML = '&#9889; Why is it slow?';
@@ -1354,7 +1409,7 @@
1354
1409
  hide(el('server-ai-result'));
1355
1410
  aiCall(ROUTES.anomaly_detection, {}, function(data) {
1356
1411
  el('server-ai-title').innerHTML = '<strong>&#9889; Query Health Report</strong>';
1357
- el('server-ai-content').innerHTML = formatMarkdown(data.report || data.raw || 'No anomalies detected.');
1412
+ el('server-ai-content').innerHTML = formatMarkdown(data.report || data.raw || 'No anomalies detected.') + copyButton('server-ai-content');
1358
1413
  show(el('server-ai-result'));
1359
1414
  anomalyBtn.disabled = false;
1360
1415
  anomalyBtn.innerHTML = '&#9889; Anomaly Detection';
@@ -1369,6 +1424,148 @@
1369
1424
  }
1370
1425
  <% end %>
1371
1426
 
1427
+ // Server: Variable Review
1428
+ var varReviewBtn = el('server-variable-review');
1429
+ if (varReviewBtn) {
1430
+ varReviewBtn.addEventListener('click', function() {
1431
+ varReviewBtn.disabled = true;
1432
+ varReviewBtn.innerHTML = '<span class="mg-spinner"></span> Reviewing...';
1433
+ hide(el('server-ai-result'));
1434
+ aiCall(ROUTES.variable_review, {}, function(data) {
1435
+ el('server-ai-title').innerHTML = '<strong>&#9889; Variable Configuration Review</strong>';
1436
+ el('server-ai-content').innerHTML = formatMarkdown(data.findings || data.raw || 'No findings.') + copyButton('server-ai-content');
1437
+ show(el('server-ai-result'));
1438
+ varReviewBtn.disabled = false;
1439
+ varReviewBtn.innerHTML = '&#9889; Variable Review';
1440
+ }, function(err) {
1441
+ el('server-ai-title').innerHTML = '<strong>&#9889; Error</strong>';
1442
+ el('server-ai-content').innerHTML = '<div class="mg-alert mg-alert-danger">' + escHtml(err) + '</div>';
1443
+ show(el('server-ai-result'));
1444
+ varReviewBtn.disabled = false;
1445
+ varReviewBtn.innerHTML = '&#9889; Variable Review';
1446
+ });
1447
+ });
1448
+ }
1449
+
1450
+ // Server: Connection Advisor
1451
+ var connAdvisorBtn = el('server-connection-advisor');
1452
+ if (connAdvisorBtn) {
1453
+ connAdvisorBtn.addEventListener('click', function() {
1454
+ connAdvisorBtn.disabled = true;
1455
+ connAdvisorBtn.innerHTML = '<span class="mg-spinner"></span> Diagnosing...';
1456
+ hide(el('server-ai-result'));
1457
+ aiCall(ROUTES.connection_advisor, {}, function(data) {
1458
+ el('server-ai-title').innerHTML = '<strong>&#9889; Connection Pressure Diagnosis</strong>';
1459
+ el('server-ai-content').innerHTML = formatMarkdown(data.diagnosis || data.raw || 'No diagnosis.') + copyButton('server-ai-content');
1460
+ show(el('server-ai-result'));
1461
+ connAdvisorBtn.disabled = false;
1462
+ connAdvisorBtn.innerHTML = '&#9889; Connection Advisor';
1463
+ }, function(err) {
1464
+ el('server-ai-title').innerHTML = '<strong>&#9889; Error</strong>';
1465
+ el('server-ai-content').innerHTML = '<div class="mg-alert mg-alert-danger">' + escHtml(err) + '</div>';
1466
+ show(el('server-ai-result'));
1467
+ connAdvisorBtn.disabled = false;
1468
+ connAdvisorBtn.innerHTML = '&#9889; Connection Advisor';
1469
+ });
1470
+ });
1471
+ }
1472
+
1473
+ // Server: InnoDB Health
1474
+ var innodbHealthBtn = el('server-innodb-health');
1475
+ if (innodbHealthBtn) {
1476
+ innodbHealthBtn.addEventListener('click', function() {
1477
+ innodbHealthBtn.disabled = true;
1478
+ innodbHealthBtn.innerHTML = '<span class="mg-spinner"></span> Interpreting...';
1479
+ hide(el('server-ai-result'));
1480
+ aiCall(ROUTES.innodb_health, {}, function(data) {
1481
+ el('server-ai-title').innerHTML = '<strong>&#9889; InnoDB Health Interpretation</strong>';
1482
+ el('server-ai-content').innerHTML = formatMarkdown(data.findings || data.raw || 'No findings.') + copyButton('server-ai-content');
1483
+ show(el('server-ai-result'));
1484
+ innodbHealthBtn.disabled = false;
1485
+ innodbHealthBtn.innerHTML = '&#9889; InnoDB Health';
1486
+ }, function(err) {
1487
+ el('server-ai-title').innerHTML = '<strong>&#9889; Error</strong>';
1488
+ el('server-ai-content').innerHTML = '<div class="mg-alert mg-alert-danger">' + escHtml(err) + '</div>';
1489
+ show(el('server-ai-result'));
1490
+ innodbHealthBtn.disabled = false;
1491
+ innodbHealthBtn.innerHTML = '&#9889; InnoDB Health';
1492
+ });
1493
+ });
1494
+ }
1495
+
1496
+ // Query Stats: Workload Digest
1497
+ var workloadBtn = el('qstats-workload-digest');
1498
+ if (workloadBtn) {
1499
+ workloadBtn.addEventListener('click', function() {
1500
+ workloadBtn.disabled = true;
1501
+ workloadBtn.innerHTML = '<span class="mg-spinner"></span> Analyzing...';
1502
+ hide(el('qstats-ai-result'));
1503
+ aiCall(ROUTES.workload_digest, {}, function(data) {
1504
+ el('qstats-ai-title').innerHTML = '<strong>&#9889; Workload Digest</strong>';
1505
+ el('qstats-ai-content').innerHTML = formatMarkdown(data.digest || data.raw || 'No digest available.') + copyButton('qstats-ai-content');
1506
+ show(el('qstats-ai-result'));
1507
+ workloadBtn.disabled = false;
1508
+ workloadBtn.innerHTML = '&#9889; Workload Digest';
1509
+ }, function(err) {
1510
+ el('qstats-ai-title').innerHTML = '<strong>&#9889; Error</strong>';
1511
+ el('qstats-ai-content').innerHTML = '<div class="mg-alert mg-alert-danger">' + escHtml(err) + '</div>';
1512
+ show(el('qstats-ai-result'));
1513
+ workloadBtn.disabled = false;
1514
+ workloadBtn.innerHTML = '&#9889; Workload Digest';
1515
+ });
1516
+ });
1517
+
1518
+ el('qstats-ai-close').addEventListener('click', function() { hide(el('qstats-ai-result')); });
1519
+ }
1520
+
1521
+ // Query Stats: Pattern Grouper
1522
+ var patternBtn = el('qstats-pattern-grouper');
1523
+ if (patternBtn) {
1524
+ patternBtn.addEventListener('click', function() {
1525
+ patternBtn.disabled = true;
1526
+ patternBtn.innerHTML = '<span class="mg-spinner"></span> Grouping...';
1527
+ hide(el('qstats-ai-result'));
1528
+ aiCall(ROUTES.pattern_grouper, {}, function(data) {
1529
+ el('qstats-ai-title').innerHTML = '<strong>&#9889; Slow Query Pattern Groups</strong>';
1530
+ el('qstats-ai-content').innerHTML = formatMarkdown(data.groups || data.raw || 'No patterns found.') + copyButton('qstats-ai-content');
1531
+ show(el('qstats-ai-result'));
1532
+ patternBtn.disabled = false;
1533
+ patternBtn.innerHTML = '&#9889; Pattern Grouper';
1534
+ }, function(err) {
1535
+ el('qstats-ai-title').innerHTML = '<strong>&#9889; Error</strong>';
1536
+ el('qstats-ai-content').innerHTML = '<div class="mg-alert mg-alert-danger">' + escHtml(err) + '</div>';
1537
+ show(el('qstats-ai-result'));
1538
+ patternBtn.disabled = false;
1539
+ patternBtn.innerHTML = '&#9889; Pattern Grouper';
1540
+ });
1541
+ });
1542
+ }
1543
+
1544
+ // Unused Indexes: Index Planner
1545
+ var indexPlannerBtn = el('unused-index-planner');
1546
+ if (indexPlannerBtn) {
1547
+ indexPlannerBtn.addEventListener('click', function() {
1548
+ indexPlannerBtn.disabled = true;
1549
+ indexPlannerBtn.innerHTML = '<span class="mg-spinner"></span> Planning...';
1550
+ hide(el('unused-ai-result'));
1551
+ aiCall(ROUTES.index_planner, {}, function(data) {
1552
+ el('unused-ai-title').innerHTML = '<strong>&#9889; Index Consolidation Plan</strong>';
1553
+ el('unused-ai-content').innerHTML = formatMarkdown(data.plan || data.raw || 'No plan generated.') + copyButton('unused-ai-content');
1554
+ show(el('unused-ai-result'));
1555
+ indexPlannerBtn.disabled = false;
1556
+ indexPlannerBtn.innerHTML = '&#9889; Index Planner';
1557
+ }, function(err) {
1558
+ el('unused-ai-title').innerHTML = '<strong>&#9889; Error</strong>';
1559
+ el('unused-ai-content').innerHTML = '<div class="mg-alert mg-alert-danger">' + escHtml(err) + '</div>';
1560
+ show(el('unused-ai-result'));
1561
+ indexPlannerBtn.disabled = false;
1562
+ indexPlannerBtn.innerHTML = '&#9889; Index Planner';
1563
+ });
1564
+ });
1565
+
1566
+ el('unused-ai-close').addEventListener('click', function() { hide(el('unused-ai-result')); });
1567
+ }
1568
+
1372
1569
  // Schema Review
1373
1570
  var schemaBtn = el('schema-review-btn');
1374
1571
  if (schemaBtn) {
@@ -1378,7 +1575,7 @@
1378
1575
  hide(el('schema-result'));
1379
1576
  var table = el('schema-table').value;
1380
1577
  aiCall(ROUTES.schema_review, { table: table }, function(data) {
1381
- el('schema-result-content').innerHTML = formatFindings(data.findings || data.raw || '');
1578
+ el('schema-result-content').innerHTML = formatFindings(data.findings || data.raw || '') + copyButton('schema-result-content');
1382
1579
  show(el('schema-result'));
1383
1580
  schemaBtn.disabled = false;
1384
1581
  schemaBtn.innerHTML = '&#9889; Analyze Schema';
@@ -1409,7 +1606,7 @@
1409
1606
  var level = (data.risk_level || '').toLowerCase();
1410
1607
  var badgeClass = level === 'critical' ? 'mg-badge-danger' : level === 'high' ? 'mg-badge-danger' : level === 'medium' ? 'mg-badge-warning' : 'mg-badge-info';
1411
1608
  el('migration-risk-badge').innerHTML = level ? '<span class="mg-badge ' + badgeClass + '" style="font-size:14px;padding:4px 12px;">Risk: ' + level.toUpperCase() + '</span>' : '';
1412
- el('migration-result-content').innerHTML = formatFindings(data.assessment || data.raw || '');
1609
+ el('migration-result-content').innerHTML = formatFindings(data.assessment || data.raw || '') + copyButton('migration-result-content');
1413
1610
  show(el('migration-result'));
1414
1611
  migrationBtn.disabled = false;
1415
1612
  migrationBtn.innerHTML = '&#9889; Assess Risk';
@@ -1565,19 +1762,12 @@
1565
1762
 
1566
1763
  if (!sections.length) return formatMarkdown(text);
1567
1764
 
1568
- var badgeColors = { danger: '#dc3545', warning: '#ffc107', info: '#17a2b8' };
1569
- var bgColors = { danger: '#fff5f5', warning: '#fffbeb', info: '#f0f9ff' };
1570
- var borderColors = { danger: '#f5c6cb', warning: '#ffeeba', info: '#bee5eb' };
1571
-
1572
1765
  return sections.map(function(sec) {
1573
1766
  var content = formatMarkdown(sec.lines.join('\n').trim());
1574
1767
  if (!content || content === '<br>') return '';
1575
- var badge = badgeColors[sec.severity] || badgeColors.info;
1576
- var bg = bgColors[sec.severity] || bgColors.info;
1577
- var border = borderColors[sec.severity] || borderColors.info;
1578
- return '<div class="mg-card mg-mb" style="border-left:4px solid ' + badge + ';background:' + bg + ';border-color:' + border + ';">' +
1579
- (sec.title ? '<div class="mg-card-header" style="background:transparent;border-bottom:1px solid ' + border + ';"><strong>' + escHtml(sec.title) + '</strong></div>' : '') +
1580
- '<div class="mg-card-body" style="font-size:13px;">' + content + '</div></div>';
1768
+ return '<div class="mg-ai-section mg-ai-' + sec.severity + ' mg-mb">' +
1769
+ (sec.title ? '<div class="mg-ai-section-header"><strong>' + escHtml(sec.title) + '</strong></div>' : '') +
1770
+ '<div class="mg-ai-section-body">' + content + '</div></div>';
1581
1771
  }).filter(function(s) { return s; }).join('');
1582
1772
  }
1583
1773
  })();
@@ -411,13 +411,13 @@
411
411
  // --- Explain ---
412
412
 
413
413
  // performance_schema DIGEST_TEXT uses normalized spacing that isn't valid SQL:
414
- // "SELECT COUNT ( * ) FROM `riders`" needs to become "SELECT COUNT(*) FROM `riders`"
415
- // "... IN ( ... )" "... IN (...)"
414
+ // "SELECT COUNT ( * ) FROM `riders`" -> needs to become "SELECT COUNT(*) FROM `riders`"
415
+ // "... IN ( ... )" -> "... IN (...)"
416
416
  // Also replaces placeholder ? with 1 so EXPLAIN can parse it.
417
417
  function normalizeDigestSql(sql) {
418
418
  return sql
419
- .replace(/\(\s+/g, '(') // "( " "("
420
- .replace(/\s+\)/g, ')') // " )" ")"
419
+ .replace(/\(\s+/g, '(') // "( " -> "("
420
+ .replace(/\s+\)/g, ')') // " )" -> ")"
421
421
  .replace(/\s*,\s*/g, ', ') // normalize comma spacing
422
422
  .replace(/\?/g, '1'); // replace ? placeholders with literal 1
423
423
  }
@@ -41,6 +41,12 @@ require "mysql_genius/core/ai/schema_review"
41
41
  require "mysql_genius/core/ai/rewrite_query"
42
42
  require "mysql_genius/core/ai/index_advisor"
43
43
  require "mysql_genius/core/ai/migration_risk"
44
+ require "mysql_genius/core/ai/variable_reviewer"
45
+ require "mysql_genius/core/ai/connection_advisor"
46
+ require "mysql_genius/core/ai/workload_digest"
47
+ require "mysql_genius/core/ai/innodb_interpreter"
48
+ require "mysql_genius/core/ai/index_planner"
49
+ require "mysql_genius/core/ai/pattern_grouper"
44
50
  require "mysql_genius/core/analysis/table_sizes"
45
51
  require "mysql_genius/core/analysis/duplicate_indexes"
46
52
  require "mysql_genius/core/analysis/query_stats"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: mysql_genius-core
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.1
4
+ version: 0.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Antarr Byrd
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-04-12 00:00:00.000000000 Z
11
+ date: 2026-04-13 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: Shared library used by the mysql_genius Rails engine and the mysql_genius-desktop
14
14
  standalone app. Contains the SQL validator, query runner, database analyses, and
@@ -24,14 +24,20 @@ files:
24
24
  - lib/mysql_genius/core.rb
25
25
  - lib/mysql_genius/core/ai/client.rb
26
26
  - lib/mysql_genius/core/ai/config.rb
27
+ - lib/mysql_genius/core/ai/connection_advisor.rb
27
28
  - lib/mysql_genius/core/ai/describe_query.rb
28
29
  - lib/mysql_genius/core/ai/index_advisor.rb
30
+ - lib/mysql_genius/core/ai/index_planner.rb
31
+ - lib/mysql_genius/core/ai/innodb_interpreter.rb
29
32
  - lib/mysql_genius/core/ai/migration_risk.rb
30
33
  - lib/mysql_genius/core/ai/optimization.rb
34
+ - lib/mysql_genius/core/ai/pattern_grouper.rb
31
35
  - lib/mysql_genius/core/ai/rewrite_query.rb
32
36
  - lib/mysql_genius/core/ai/schema_context_builder.rb
33
37
  - lib/mysql_genius/core/ai/schema_review.rb
34
38
  - lib/mysql_genius/core/ai/suggestion.rb
39
+ - lib/mysql_genius/core/ai/variable_reviewer.rb
40
+ - lib/mysql_genius/core/ai/workload_digest.rb
35
41
  - lib/mysql_genius/core/analysis/columns.rb
36
42
  - lib/mysql_genius/core/analysis/duplicate_indexes.rb
37
43
  - lib/mysql_genius/core/analysis/query_stats.rb