mysql_genius 0.3.2 → 0.4.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 +4 -4
- data/.github/workflows/publish.yml +21 -2
- data/.gitignore +1 -0
- data/.rubocop.yml +1 -1
- data/CHANGELOG.md +18 -0
- data/Gemfile +2 -0
- data/README.md +27 -0
- data/app/controllers/concerns/mysql_genius/ai_features.rb +31 -11
- data/app/controllers/concerns/mysql_genius/database_analysis.rb +15 -263
- data/app/controllers/concerns/mysql_genius/query_execution.rb +39 -81
- data/lib/mysql_genius/core/connection/active_record_adapter.rb +77 -0
- data/lib/mysql_genius/version.rb +1 -1
- data/lib/mysql_genius.rb +2 -1
- data/mysql_genius.gemspec +2 -1
- metadata +16 -7
- data/app/services/mysql_genius/ai_client.rb +0 -91
- data/app/services/mysql_genius/ai_optimization_service.rb +0 -60
- data/app/services/mysql_genius/ai_suggestion_service.rb +0 -59
- data/docs/superpowers/plans/2026-04-08-dashboard-first-redesign.md +0 -741
- data/docs/superpowers/specs/2026-04-08-dashboard-first-redesign.md +0 -87
- data/lib/mysql_genius/sql_validator.rb +0 -57
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2ccbb1061d5424d82fe20ee1eea6793a582bb78fca163336453a794a465499ad
|
|
4
|
+
data.tar.gz: 267dc2a66b94b835f597e2eb6bc9540f261dad80a45a9d3fe9ddba1c714f2b5b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: bc2a345c4bfc5d37eecd0567e6786998a73f1e6c0109b9b1d86be156a4994576bfe7a7653b86162a3a8f20ee00a76c94c7c36d1a780f655e602464aa4f6ba3f4
|
|
7
|
+
data.tar.gz: 0b9bd46ba51a668657978cacc2ef44e1928a033ed20c9f2883ac93ffbd5025587c57789991fa4fa060617146eaf74bbb8c3961d0e9d44660a0a78c3580886241
|
|
@@ -15,6 +15,11 @@ jobs:
|
|
|
15
15
|
ruby-version: 3.3
|
|
16
16
|
bundler-cache: true
|
|
17
17
|
- run: bundle exec rspec
|
|
18
|
+
- name: Test mysql_genius-core gem
|
|
19
|
+
working-directory: gems/mysql_genius-core
|
|
20
|
+
run: |
|
|
21
|
+
bundle install
|
|
22
|
+
bundle exec rspec
|
|
18
23
|
|
|
19
24
|
publish:
|
|
20
25
|
needs: test
|
|
@@ -24,9 +29,23 @@ jobs:
|
|
|
24
29
|
- uses: ruby/setup-ruby@v1
|
|
25
30
|
with:
|
|
26
31
|
ruby-version: 3.3
|
|
27
|
-
|
|
32
|
+
|
|
33
|
+
# Build and push mysql_genius-core FIRST so that mysql_genius's
|
|
34
|
+
# runtime dependency on it can resolve at gem install time.
|
|
35
|
+
- name: Build mysql_genius-core
|
|
36
|
+
working-directory: gems/mysql_genius-core
|
|
37
|
+
run: gem build mysql_genius-core.gemspec
|
|
38
|
+
|
|
39
|
+
- name: Publish mysql_genius-core
|
|
40
|
+
working-directory: gems/mysql_genius-core
|
|
41
|
+
run: gem push mysql_genius-core-*.gem
|
|
42
|
+
env:
|
|
43
|
+
GEM_HOST_API_KEY: ${{ secrets.RUBYGEMS_API_KEY }}
|
|
44
|
+
|
|
45
|
+
- name: Build mysql_genius
|
|
28
46
|
run: gem build mysql_genius.gemspec
|
|
29
|
-
|
|
47
|
+
|
|
48
|
+
- name: Publish mysql_genius
|
|
30
49
|
run: gem push mysql_genius-*.gem
|
|
31
50
|
env:
|
|
32
51
|
GEM_HOST_API_KEY: ${{ secrets.RUBYGEMS_API_KEY }}
|
data/.gitignore
CHANGED
data/.rubocop.yml
CHANGED
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.4.0
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
- **Boot-order bug: `MysqlGenius::Core::Connection::ActiveRecordAdapter` was not required by `lib/mysql_genius.rb`** — shipped in Phase 1a but never wired into the production require chain. Invisible to CI because the adapter's spec file explicitly required it, and invisible in development because pre-Phase-1b concerns didn't reference it. Phase 1b's extracted delegators instantiate it in every action, so the missing require would have surfaced as `uninitialized constant` on every tab (tables, query stats, unused indexes, duplicate indexes, server overview, execute, explain, AI suggest, AI optimize) in any host app that installed 0.4.0 without the fix. `lib/mysql_genius.rb` now explicitly requires the adapter after loading `mysql_genius/core`, and `spec/spec_helper.rb` has a regression guard that aborts the spec suite at boot if the constant is not reachable via a plain `require "mysql_genius"`.
|
|
7
|
+
|
|
8
|
+
### Changed
|
|
9
|
+
- **Internal refactor: extracted Rails-free core library into a new `mysql_genius-core` gem.** The validator, AI services, value objects, database analyses, query runner, and query explainer now live in `mysql_genius-core`; the `mysql_genius` Rails engine delegates through a new `Core::Connection::ActiveRecordAdapter`. Public API, routes, config DSL, and JSON response shapes are unchanged — host apps see no difference after `bundle update`. See [the design spec](docs/superpowers/specs/2026-04-10-desktop-app-design.md) for the motivation: the new core gem is the foundation for a forthcoming `mysql_genius-desktop` standalone app.
|
|
10
|
+
- `mysql_genius` now declares a runtime dependency on `mysql_genius-core ~> 0.4.0`. The two gems release in lockstep under matching version numbers (0.4.0 is the first paired release); the dependency resolves transitively, so host apps do not need to add `mysql_genius-core` to their Gemfile.
|
|
11
|
+
- `MysqlGenius::SqlValidator` moved to `MysqlGenius::Core::SqlValidator`.
|
|
12
|
+
- `MysqlGenius::AiClient`, `MysqlGenius::AiSuggestionService`, `MysqlGenius::AiOptimizationService` moved to `MysqlGenius::Core::Ai::{Client, Suggestion, Optimization}` and now take an explicit `Core::Ai::Config` instead of reading `MysqlGenius.configuration` at construction time.
|
|
13
|
+
- The 5 database analyses (`table_sizes`, `duplicate_indexes`, `query_stats`, `unused_indexes`, `server_overview`) moved from the `DatabaseAnalysis` controller concern into `MysqlGenius::Core::Analysis::*` classes, each taking a `Core::Connection`. The concern shrunk from ~295 lines to 47 lines of thin delegating wrappers.
|
|
14
|
+
- `MysqlGenius::Core::QueryRunner` now owns SQL validation, row-limit application, timeout-hint wrapping (MySQL / MariaDB flavors), execution, column masking, and timeout detection. The `execute` controller action delegates to it. Audit logging stays in the Rails adapter.
|
|
15
|
+
- `MysqlGenius::Core::QueryExplainer` now owns the EXPLAIN path with optional validation-skipping for captured slow queries. The `explain` controller action delegates to it.
|
|
16
|
+
|
|
17
|
+
### Documentation
|
|
18
|
+
- Added README troubleshooting section covering `SSL_connect ... EC lib` / `unable to decode issuer public key` errors that hit Ruby 2.7 + OpenSSL 1.1.x users talking to Google Trust Services-backed hosts like Ollama Cloud. Recommends local Ollama (`http://localhost:11434`) as the fastest unblock, `SSL_CERT_FILE` pointing at a fresher CA bundle as an intermediate fix, and upgrading to Ruby 3.2+ as the durable fix.
|
|
19
|
+
- Added `docs/superpowers/specs/2026-04-10-desktop-app-design.md` — the full design spec for the eventual `mysql_genius-desktop` standalone app.
|
|
20
|
+
|
|
3
21
|
## 0.3.2
|
|
4
22
|
|
|
5
23
|
### Fixed
|
data/Gemfile
CHANGED
data/README.md
CHANGED
|
@@ -103,6 +103,33 @@ end
|
|
|
103
103
|
|
|
104
104
|
For all provider examples, see the [AI Features guide](https://github.com/antarr/mysql_genius/wiki/AI-Features).
|
|
105
105
|
|
|
106
|
+
### Troubleshooting TLS errors with Ollama Cloud
|
|
107
|
+
|
|
108
|
+
If you see `SSL_connect ... unable to decode issuer public key` or `SSL_connect ... EC lib` when using an AI feature, your Rails host's Ruby is linked against an older OpenSSL that can't verify modern ECDSA certificate chains (Ollama Cloud is served behind Google Trust Services, whose ECDSA roots trip up OpenSSL 1.1.x and earlier). This is not specific to `mysql_genius` — it affects any Ruby HTTPS call to those hosts.
|
|
109
|
+
|
|
110
|
+
Three ways to fix it, in order of effort:
|
|
111
|
+
|
|
112
|
+
**Use a local Ollama instead.** Point the endpoint at `http://localhost:11434/v1/chat/completions`. Your Rails app talks plain HTTP to the local `ollama` binary, which handles the upstream TLS itself using its own modern cert handling. For cloud-backed models, run `ollama signin` once and use the `:cloud` model suffix (e.g., `gemma3:27b-cloud`).
|
|
113
|
+
|
|
114
|
+
```ruby
|
|
115
|
+
MysqlGenius.configure do |config|
|
|
116
|
+
config.ai_endpoint = "http://localhost:11434/v1/chat/completions"
|
|
117
|
+
config.ai_api_key = "unused-but-required" # any non-empty string
|
|
118
|
+
config.ai_model = "gemma3:27b-cloud" # or any local model
|
|
119
|
+
config.ai_auth_style = :bearer
|
|
120
|
+
end
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
**Point Ruby at a fresher CA bundle.** Set `SSL_CERT_FILE` in the environment where Rails boots. On macOS with Homebrew:
|
|
124
|
+
|
|
125
|
+
```bash
|
|
126
|
+
SSL_CERT_FILE=/opt/homebrew/etc/openssl@3/cert.pem bin/rails s
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
This helps if the problem is a stale trust store, but does **not** help if the underlying OpenSSL itself is too old to parse the cert's key algorithm.
|
|
130
|
+
|
|
131
|
+
**Upgrade Ruby** to 3.2 or newer. Ruby 2.7 is end-of-life (March 2023); newer Rubies link against OpenSSL 3.x which handles modern ECDSA chains correctly. This is the durable fix and the one we recommend for any project still on 2.7.
|
|
132
|
+
|
|
106
133
|
## Compatibility
|
|
107
134
|
|
|
108
135
|
| Rails | Ruby |
|
|
@@ -12,7 +12,9 @@ module MysqlGenius
|
|
|
12
12
|
prompt = params[:prompt].to_s.strip
|
|
13
13
|
return render(json: { error: "Please describe what you want to query." }, status: :unprocessable_entity) if prompt.blank?
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
connection = MysqlGenius::Core::Connection::ActiveRecordAdapter.new(ActiveRecord::Base.connection)
|
|
16
|
+
service = MysqlGenius::Core::Ai::Suggestion.new(connection, ai_client, ai_config_for_core)
|
|
17
|
+
result = service.call(prompt, queryable_tables)
|
|
16
18
|
sql = sanitize_ai_sql(result["sql"].to_s)
|
|
17
19
|
render(json: { sql: sql, explanation: result["explanation"] })
|
|
18
20
|
rescue StandardError => e
|
|
@@ -31,7 +33,9 @@ module MysqlGenius
|
|
|
31
33
|
return render(json: { error: "SQL and EXPLAIN output are required." }, status: :unprocessable_entity)
|
|
32
34
|
end
|
|
33
35
|
|
|
34
|
-
|
|
36
|
+
connection = MysqlGenius::Core::Connection::ActiveRecordAdapter.new(ActiveRecord::Base.connection)
|
|
37
|
+
service = MysqlGenius::Core::Ai::Optimization.new(connection, ai_client, ai_config_for_core)
|
|
38
|
+
result = service.call(sql, explain_rows, queryable_tables)
|
|
35
39
|
render(json: result)
|
|
36
40
|
rescue StandardError => e
|
|
37
41
|
render(json: { error: "Optimization failed: #{e.message}" }, status: :unprocessable_entity)
|
|
@@ -56,7 +60,7 @@ module MysqlGenius
|
|
|
56
60
|
{ role: "user", content: sql },
|
|
57
61
|
]
|
|
58
62
|
|
|
59
|
-
result =
|
|
63
|
+
result = ai_client.chat(messages: messages)
|
|
60
64
|
render(json: result)
|
|
61
65
|
rescue StandardError => e
|
|
62
66
|
render(json: { error: "Explanation failed: #{e.message}" }, status: :unprocessable_entity)
|
|
@@ -100,7 +104,7 @@ module MysqlGenius
|
|
|
100
104
|
{ role: "user", content: schema_desc },
|
|
101
105
|
]
|
|
102
106
|
|
|
103
|
-
result =
|
|
107
|
+
result = ai_client.chat(messages: messages)
|
|
104
108
|
render(json: result)
|
|
105
109
|
rescue StandardError => e
|
|
106
110
|
render(json: { error: "Schema review failed: #{e.message}" }, status: :unprocessable_entity)
|
|
@@ -136,7 +140,7 @@ module MysqlGenius
|
|
|
136
140
|
{ role: "user", content: sql },
|
|
137
141
|
]
|
|
138
142
|
|
|
139
|
-
result =
|
|
143
|
+
result = ai_client.chat(messages: messages)
|
|
140
144
|
render(json: result)
|
|
141
145
|
rescue StandardError => e
|
|
142
146
|
render(json: { error: "Rewrite failed: #{e.message}" }, status: :unprocessable_entity)
|
|
@@ -150,7 +154,7 @@ module MysqlGenius
|
|
|
150
154
|
return render(json: { error: "SQL and EXPLAIN output are required." }, status: :unprocessable_entity) if sql.blank? || explain_rows.blank?
|
|
151
155
|
|
|
152
156
|
connection = ActiveRecord::Base.connection
|
|
153
|
-
tables_in_query = SqlValidator.extract_table_references(sql, connection)
|
|
157
|
+
tables_in_query = MysqlGenius::Core::SqlValidator.extract_table_references(sql, connection)
|
|
154
158
|
|
|
155
159
|
index_detail = tables_in_query.map do |t|
|
|
156
160
|
indexes = connection.indexes(t).map { |idx| "#{"UNIQUE " if idx.unique}INDEX #{idx.name} (#{idx.columns.join(", ")})" }
|
|
@@ -175,7 +179,7 @@ module MysqlGenius
|
|
|
175
179
|
{ role: "user", content: "Query:\n#{sql}\n\nEXPLAIN:\n#{explain_rows.map { |r| r.join(" | ") }.join("\n")}\n\nCurrent Indexes:\n#{index_detail}" },
|
|
176
180
|
]
|
|
177
181
|
|
|
178
|
-
result =
|
|
182
|
+
result = ai_client.chat(messages: messages)
|
|
179
183
|
render(json: result)
|
|
180
184
|
rescue StandardError => e
|
|
181
185
|
render(json: { error: "Index advisor failed: #{e.message}" }, status: :unprocessable_entity)
|
|
@@ -235,7 +239,7 @@ module MysqlGenius
|
|
|
235
239
|
{ role: "user", content: "Recent Slow Queries (last #{slow_data.size}):\n#{slow_summary.presence || "None captured"}\n\nTop Queries by Total Time:\n#{stats_summary.presence || "Not available"}" },
|
|
236
240
|
]
|
|
237
241
|
|
|
238
|
-
result =
|
|
242
|
+
result = ai_client.chat(messages: messages)
|
|
239
243
|
render(json: result)
|
|
240
244
|
rescue StandardError => e
|
|
241
245
|
render(json: { error: "Anomaly detection failed: #{e.message}" }, status: :unprocessable_entity)
|
|
@@ -307,7 +311,7 @@ module MysqlGenius
|
|
|
307
311
|
{ role: "user", content: "PROCESSLIST:\n#{process_info}\n\nKey Status:\n#{key_stats}\n\nInnoDB Status (excerpt):\n#{innodb_status.presence || "Not available"}\n\nRecent Slow Queries:\n#{slow_summary.presence || "None captured"}" },
|
|
308
312
|
]
|
|
309
313
|
|
|
310
|
-
result =
|
|
314
|
+
result = ai_client.chat(messages: messages)
|
|
311
315
|
render(json: result)
|
|
312
316
|
rescue StandardError => e
|
|
313
317
|
render(json: { error: "Root cause analysis failed: #{e.message}" }, status: :unprocessable_entity)
|
|
@@ -366,7 +370,7 @@ module MysqlGenius
|
|
|
366
370
|
{ role: "user", content: "Migration:\n#{migration_sql}\n\nAffected Tables:\n#{table_info.presence || "Could not determine"}\n\nActive Queries on These Tables:\n#{active.presence || "None found or performance_schema unavailable"}" },
|
|
367
371
|
]
|
|
368
372
|
|
|
369
|
-
result =
|
|
373
|
+
result = ai_client.chat(messages: messages)
|
|
370
374
|
render(json: result)
|
|
371
375
|
rescue StandardError => e
|
|
372
376
|
render(json: { error: "Migration risk assessment failed: #{e.message}" }, status: :unprocessable_entity)
|
|
@@ -374,6 +378,22 @@ module MysqlGenius
|
|
|
374
378
|
|
|
375
379
|
private
|
|
376
380
|
|
|
381
|
+
def ai_client
|
|
382
|
+
MysqlGenius::Core::Ai::Client.new(ai_config_for_core)
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
def ai_config_for_core
|
|
386
|
+
cfg = mysql_genius_config
|
|
387
|
+
MysqlGenius::Core::Ai::Config.new(
|
|
388
|
+
client: cfg.ai_client,
|
|
389
|
+
endpoint: cfg.ai_endpoint,
|
|
390
|
+
api_key: cfg.ai_api_key,
|
|
391
|
+
model: cfg.ai_model,
|
|
392
|
+
auth_style: cfg.ai_auth_style,
|
|
393
|
+
system_context: cfg.ai_system_context,
|
|
394
|
+
)
|
|
395
|
+
end
|
|
396
|
+
|
|
377
397
|
def ai_not_configured
|
|
378
398
|
render(json: { error: "AI features are not configured." }, status: :not_found)
|
|
379
399
|
end
|
|
@@ -388,7 +408,7 @@ module MysqlGenius
|
|
|
388
408
|
|
|
389
409
|
def build_schema_for_query(sql)
|
|
390
410
|
connection = ActiveRecord::Base.connection
|
|
391
|
-
tables = SqlValidator.extract_table_references(sql, connection)
|
|
411
|
+
tables = MysqlGenius::Core::SqlValidator.extract_table_references(sql, connection)
|
|
392
412
|
tables.map do |t|
|
|
393
413
|
cols = connection.columns(t).map { |c| "#{c.name} (#{c.type})" }
|
|
394
414
|
"#{t}: #{cols.join(", ")}"
|
|
@@ -5,289 +5,41 @@ module MysqlGenius
|
|
|
5
5
|
extend ActiveSupport::Concern
|
|
6
6
|
|
|
7
7
|
def duplicate_indexes
|
|
8
|
-
connection = ActiveRecord::Base.connection
|
|
9
|
-
duplicates =
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
indexes = connection.indexes(table)
|
|
13
|
-
next if indexes.size < 2
|
|
14
|
-
|
|
15
|
-
indexes.each do |idx|
|
|
16
|
-
indexes.each do |other|
|
|
17
|
-
next if idx.name == other.name
|
|
18
|
-
|
|
19
|
-
# idx is duplicate if its columns are a left-prefix of other's columns
|
|
20
|
-
next unless idx.columns.size <= other.columns.size &&
|
|
21
|
-
other.columns.first(idx.columns.size) == idx.columns &&
|
|
22
|
-
!(idx.unique && !other.unique) # don't drop a unique index covered by a non-unique one
|
|
23
|
-
|
|
24
|
-
duplicates << {
|
|
25
|
-
table: table,
|
|
26
|
-
duplicate_index: idx.name,
|
|
27
|
-
duplicate_columns: idx.columns,
|
|
28
|
-
covered_by_index: other.name,
|
|
29
|
-
covered_by_columns: other.columns,
|
|
30
|
-
unique: idx.unique,
|
|
31
|
-
}
|
|
32
|
-
end
|
|
33
|
-
end
|
|
34
|
-
end
|
|
35
|
-
|
|
36
|
-
# Deduplicate (A covers B and B covers A when columns are identical -- keep only one)
|
|
37
|
-
seen = Set.new
|
|
38
|
-
duplicates = duplicates.reject do |d|
|
|
39
|
-
key = [d[:table], [d[:duplicate_index], d[:covered_by_index]].sort].flatten.join(":")
|
|
40
|
-
if seen.include?(key)
|
|
41
|
-
true
|
|
42
|
-
else
|
|
43
|
-
(seen.add(key)
|
|
44
|
-
false)
|
|
45
|
-
end
|
|
46
|
-
end
|
|
47
|
-
|
|
8
|
+
connection = MysqlGenius::Core::Connection::ActiveRecordAdapter.new(ActiveRecord::Base.connection)
|
|
9
|
+
duplicates = MysqlGenius::Core::Analysis::DuplicateIndexes
|
|
10
|
+
.new(connection, blocked_tables: mysql_genius_config.blocked_tables)
|
|
11
|
+
.call
|
|
48
12
|
render(json: duplicates)
|
|
49
13
|
end
|
|
50
14
|
|
|
51
15
|
def table_sizes
|
|
52
|
-
connection = ActiveRecord::Base.connection
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
results = connection.exec_query(<<~SQL)
|
|
56
|
-
SELECT
|
|
57
|
-
table_name,
|
|
58
|
-
engine,
|
|
59
|
-
table_collation,
|
|
60
|
-
auto_increment,
|
|
61
|
-
update_time,
|
|
62
|
-
ROUND(data_length / 1024 / 1024, 2) AS data_mb,
|
|
63
|
-
ROUND(index_length / 1024 / 1024, 2) AS index_mb,
|
|
64
|
-
ROUND((data_length + index_length) / 1024 / 1024, 2) AS total_mb,
|
|
65
|
-
ROUND(data_free / 1024 / 1024, 2) AS fragmented_mb
|
|
66
|
-
FROM information_schema.tables
|
|
67
|
-
WHERE table_schema = #{connection.quote(db_name)}
|
|
68
|
-
AND table_type = 'BASE TABLE'
|
|
69
|
-
ORDER BY (data_length + index_length) DESC
|
|
70
|
-
SQL
|
|
71
|
-
|
|
72
|
-
tables = results.map do |row|
|
|
73
|
-
table_name = row["table_name"] || row["TABLE_NAME"]
|
|
74
|
-
row_count = begin
|
|
75
|
-
connection.select_value("SELECT COUNT(*) FROM #{connection.quote_table_name(table_name)}")
|
|
76
|
-
rescue StandardError
|
|
77
|
-
nil
|
|
78
|
-
end
|
|
79
|
-
|
|
80
|
-
total_mb = (row["total_mb"] || 0).to_f
|
|
81
|
-
fragmented_mb = (row["fragmented_mb"] || 0).to_f
|
|
82
|
-
|
|
83
|
-
{
|
|
84
|
-
table: table_name,
|
|
85
|
-
rows: row_count,
|
|
86
|
-
engine: row["engine"] || row["ENGINE"],
|
|
87
|
-
collation: row["table_collation"] || row["TABLE_COLLATION"],
|
|
88
|
-
auto_increment: row["auto_increment"] || row["AUTO_INCREMENT"],
|
|
89
|
-
updated_at: row["update_time"] || row["UPDATE_TIME"],
|
|
90
|
-
data_mb: (row["data_mb"] || 0).to_f,
|
|
91
|
-
index_mb: (row["index_mb"] || 0).to_f,
|
|
92
|
-
total_mb: total_mb,
|
|
93
|
-
fragmented_mb: fragmented_mb,
|
|
94
|
-
needs_optimize: total_mb > 0 && fragmented_mb > (total_mb * 0.1),
|
|
95
|
-
}
|
|
96
|
-
end
|
|
97
|
-
|
|
16
|
+
connection = MysqlGenius::Core::Connection::ActiveRecordAdapter.new(ActiveRecord::Base.connection)
|
|
17
|
+
tables = MysqlGenius::Core::Analysis::TableSizes.new(connection).call
|
|
98
18
|
render(json: tables)
|
|
99
19
|
end
|
|
100
20
|
|
|
101
21
|
def query_stats
|
|
102
|
-
connection = ActiveRecord::Base.connection
|
|
103
|
-
sort =
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
when "total_time" then "SUM_TIMER_WAIT DESC"
|
|
107
|
-
when "avg_time" then "AVG_TIMER_WAIT DESC"
|
|
108
|
-
when "calls" then "COUNT_STAR DESC"
|
|
109
|
-
when "rows_examined" then "SUM_ROWS_EXAMINED DESC"
|
|
110
|
-
end
|
|
111
|
-
|
|
112
|
-
limit = params.fetch(:limit, 50).to_i.clamp(1, 50)
|
|
113
|
-
|
|
114
|
-
results = connection.exec_query(<<~SQL)
|
|
115
|
-
SELECT
|
|
116
|
-
DIGEST_TEXT,
|
|
117
|
-
COUNT_STAR AS calls,
|
|
118
|
-
ROUND(SUM_TIMER_WAIT / 1000000000, 1) AS total_time_ms,
|
|
119
|
-
ROUND(AVG_TIMER_WAIT / 1000000000, 1) AS avg_time_ms,
|
|
120
|
-
ROUND(MAX_TIMER_WAIT / 1000000000, 1) AS max_time_ms,
|
|
121
|
-
SUM_ROWS_EXAMINED AS rows_examined,
|
|
122
|
-
SUM_ROWS_SENT AS rows_sent,
|
|
123
|
-
SUM_CREATED_TMP_DISK_TABLES AS tmp_disk_tables,
|
|
124
|
-
SUM_SORT_ROWS AS sort_rows,
|
|
125
|
-
FIRST_SEEN,
|
|
126
|
-
LAST_SEEN
|
|
127
|
-
FROM performance_schema.events_statements_summary_by_digest
|
|
128
|
-
WHERE SCHEMA_NAME = #{connection.quote(connection.current_database)}
|
|
129
|
-
AND DIGEST_TEXT IS NOT NULL
|
|
130
|
-
AND DIGEST_TEXT NOT LIKE 'EXPLAIN%'
|
|
131
|
-
AND DIGEST_TEXT NOT LIKE '%`information_schema`%'
|
|
132
|
-
AND DIGEST_TEXT NOT LIKE '%`performance_schema`%'
|
|
133
|
-
AND DIGEST_TEXT NOT LIKE '%information_schema.%'
|
|
134
|
-
AND DIGEST_TEXT NOT LIKE '%performance_schema.%'
|
|
135
|
-
AND DIGEST_TEXT NOT LIKE 'SHOW %'
|
|
136
|
-
AND DIGEST_TEXT NOT LIKE 'SET STATEMENT %'
|
|
137
|
-
AND DIGEST_TEXT NOT LIKE 'SELECT VERSION ( )%'
|
|
138
|
-
AND DIGEST_TEXT NOT LIKE 'SELECT @@%'
|
|
139
|
-
ORDER BY #{order_clause}
|
|
140
|
-
LIMIT #{limit}
|
|
141
|
-
SQL
|
|
142
|
-
|
|
143
|
-
queries = results.map do |row|
|
|
144
|
-
digest = row["DIGEST_TEXT"] || row["digest_text"] || ""
|
|
145
|
-
calls = (row["calls"] || row["CALLS"] || 0).to_i
|
|
146
|
-
rows_examined = (row["rows_examined"] || row["ROWS_EXAMINED"] || 0).to_i
|
|
147
|
-
rows_sent = (row["rows_sent"] || row["ROWS_SENT"] || 0).to_i
|
|
148
|
-
{
|
|
149
|
-
sql: digest.truncate(500),
|
|
150
|
-
calls: calls,
|
|
151
|
-
total_time_ms: row["total_time_ms"].to_f,
|
|
152
|
-
avg_time_ms: row["avg_time_ms"].to_f,
|
|
153
|
-
max_time_ms: row["max_time_ms"].to_f,
|
|
154
|
-
rows_examined: rows_examined,
|
|
155
|
-
rows_sent: rows_sent,
|
|
156
|
-
rows_ratio: rows_sent > 0 ? (rows_examined.to_f / rows_sent).round(1) : 0,
|
|
157
|
-
tmp_disk_tables: (row["tmp_disk_tables"] || row["TMP_DISK_TABLES"] || 0).to_i,
|
|
158
|
-
sort_rows: (row["sort_rows"] || row["SORT_ROWS"] || 0).to_i,
|
|
159
|
-
first_seen: row["FIRST_SEEN"] || row["first_seen"],
|
|
160
|
-
last_seen: row["LAST_SEEN"] || row["last_seen"],
|
|
161
|
-
}
|
|
162
|
-
end
|
|
163
|
-
|
|
22
|
+
connection = MysqlGenius::Core::Connection::ActiveRecordAdapter.new(ActiveRecord::Base.connection)
|
|
23
|
+
sort = params[:sort].to_s
|
|
24
|
+
limit = params.fetch(:limit, MysqlGenius::Core::Analysis::QueryStats::MAX_LIMIT).to_i
|
|
25
|
+
queries = MysqlGenius::Core::Analysis::QueryStats.new(connection).call(sort: sort, limit: limit)
|
|
164
26
|
render(json: queries)
|
|
165
27
|
rescue ActiveRecord::StatementInvalid => e
|
|
166
28
|
render(json: { error: "Query statistics require performance_schema to be enabled. #{e.message.split(":").last.strip}" }, status: :unprocessable_entity)
|
|
167
29
|
end
|
|
168
30
|
|
|
169
31
|
def unused_indexes
|
|
170
|
-
connection = ActiveRecord::Base.connection
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
results = connection.exec_query(<<~SQL)
|
|
174
|
-
SELECT
|
|
175
|
-
s.OBJECT_SCHEMA AS table_schema,
|
|
176
|
-
s.OBJECT_NAME AS table_name,
|
|
177
|
-
s.INDEX_NAME AS index_name,
|
|
178
|
-
s.COUNT_READ AS `reads`,
|
|
179
|
-
s.COUNT_WRITE AS `writes`,
|
|
180
|
-
t.TABLE_ROWS AS table_rows
|
|
181
|
-
FROM performance_schema.table_io_waits_summary_by_index_usage s
|
|
182
|
-
JOIN information_schema.tables t
|
|
183
|
-
ON t.TABLE_SCHEMA = s.OBJECT_SCHEMA AND t.TABLE_NAME = s.OBJECT_NAME
|
|
184
|
-
WHERE s.OBJECT_SCHEMA = #{connection.quote(db_name)}
|
|
185
|
-
AND s.INDEX_NAME IS NOT NULL
|
|
186
|
-
AND s.INDEX_NAME != 'PRIMARY'
|
|
187
|
-
AND s.COUNT_READ = 0
|
|
188
|
-
AND t.TABLE_ROWS > 0
|
|
189
|
-
ORDER BY s.COUNT_WRITE DESC
|
|
190
|
-
SQL
|
|
191
|
-
|
|
192
|
-
indexes = results.map do |row|
|
|
193
|
-
table = row["table_name"] || row["TABLE_NAME"]
|
|
194
|
-
index_name = row["index_name"] || row["INDEX_NAME"]
|
|
195
|
-
{
|
|
196
|
-
table: table,
|
|
197
|
-
index_name: index_name,
|
|
198
|
-
reads: (row["reads"] || row["READS"] || 0).to_i,
|
|
199
|
-
writes: (row["writes"] || row["WRITES"] || 0).to_i,
|
|
200
|
-
table_rows: (row["table_rows"] || row["TABLE_ROWS"] || 0).to_i,
|
|
201
|
-
drop_sql: "ALTER TABLE `#{table}` DROP INDEX `#{index_name}`;",
|
|
202
|
-
}
|
|
203
|
-
end
|
|
204
|
-
|
|
32
|
+
connection = MysqlGenius::Core::Connection::ActiveRecordAdapter.new(ActiveRecord::Base.connection)
|
|
33
|
+
indexes = MysqlGenius::Core::Analysis::UnusedIndexes.new(connection).call
|
|
205
34
|
render(json: indexes)
|
|
206
35
|
rescue ActiveRecord::StatementInvalid => e
|
|
207
36
|
render(json: { error: "Unused index detection requires performance_schema. #{e.message.split(":").last.strip}" }, status: :unprocessable_entity)
|
|
208
37
|
end
|
|
209
38
|
|
|
210
39
|
def server_overview
|
|
211
|
-
connection = ActiveRecord::Base.connection
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
status_rows = connection.exec_query("SHOW GLOBAL STATUS")
|
|
215
|
-
status = {}
|
|
216
|
-
status_rows.each { |r| status[(r["Variable_name"] || r["variable_name"]).to_s] = (r["Value"] || r["value"]).to_s }
|
|
217
|
-
|
|
218
|
-
# Global variables
|
|
219
|
-
vars_rows = connection.exec_query("SHOW GLOBAL VARIABLES")
|
|
220
|
-
vars = {}
|
|
221
|
-
vars_rows.each { |r| vars[(r["Variable_name"] || r["variable_name"]).to_s] = (r["Value"] || r["value"]).to_s }
|
|
222
|
-
|
|
223
|
-
version = connection.select_value("SELECT VERSION()")
|
|
224
|
-
uptime_seconds = status["Uptime"].to_i
|
|
225
|
-
|
|
226
|
-
days = uptime_seconds / 86400
|
|
227
|
-
hours = (uptime_seconds % 86400) / 3600
|
|
228
|
-
minutes = (uptime_seconds % 3600) / 60
|
|
229
|
-
|
|
230
|
-
max_conn = vars["max_connections"].to_i
|
|
231
|
-
current_conn = status["Threads_connected"].to_i
|
|
232
|
-
conn_pct = max_conn > 0 ? ((current_conn.to_f / max_conn) * 100).round(1) : 0
|
|
233
|
-
|
|
234
|
-
buffer_pool_bytes = vars["innodb_buffer_pool_size"].to_i
|
|
235
|
-
buffer_pool_mb = (buffer_pool_bytes / 1024.0 / 1024.0).round(1)
|
|
236
|
-
|
|
237
|
-
# Buffer pool hit rate
|
|
238
|
-
reads = status["Innodb_buffer_pool_read_requests"].to_f
|
|
239
|
-
disk_reads = status["Innodb_buffer_pool_reads"].to_f
|
|
240
|
-
hit_rate = reads > 0 ? (((reads - disk_reads) / reads) * 100).round(2) : 0
|
|
241
|
-
|
|
242
|
-
# Tmp tables
|
|
243
|
-
tmp_tables = status["Created_tmp_tables"].to_i
|
|
244
|
-
tmp_disk_tables = status["Created_tmp_disk_tables"].to_i
|
|
245
|
-
tmp_disk_pct = tmp_tables > 0 ? ((tmp_disk_tables.to_f / tmp_tables) * 100).round(1) : 0
|
|
246
|
-
|
|
247
|
-
# Slow queries from MySQL's own counter
|
|
248
|
-
slow_queries = status["Slow_queries"].to_i
|
|
249
|
-
|
|
250
|
-
# Questions (total queries)
|
|
251
|
-
questions = status["Questions"].to_i
|
|
252
|
-
qps = uptime_seconds > 0 ? (questions.to_f / uptime_seconds).round(1) : 0
|
|
253
|
-
|
|
254
|
-
render(json: {
|
|
255
|
-
server: {
|
|
256
|
-
version: version,
|
|
257
|
-
uptime: "#{days}d #{hours}h #{minutes}m",
|
|
258
|
-
uptime_seconds: uptime_seconds,
|
|
259
|
-
},
|
|
260
|
-
connections: {
|
|
261
|
-
max: max_conn,
|
|
262
|
-
current: current_conn,
|
|
263
|
-
usage_pct: conn_pct,
|
|
264
|
-
threads_running: status["Threads_running"].to_i,
|
|
265
|
-
threads_cached: status["Threads_cached"].to_i,
|
|
266
|
-
threads_created: status["Threads_created"].to_i,
|
|
267
|
-
aborted_connects: status["Aborted_connects"].to_i,
|
|
268
|
-
aborted_clients: status["Aborted_clients"].to_i,
|
|
269
|
-
max_used: status["Max_used_connections"].to_i,
|
|
270
|
-
},
|
|
271
|
-
innodb: {
|
|
272
|
-
buffer_pool_mb: buffer_pool_mb,
|
|
273
|
-
buffer_pool_hit_rate: hit_rate,
|
|
274
|
-
buffer_pool_pages_dirty: status["Innodb_buffer_pool_pages_dirty"].to_i,
|
|
275
|
-
buffer_pool_pages_free: status["Innodb_buffer_pool_pages_free"].to_i,
|
|
276
|
-
buffer_pool_pages_total: status["Innodb_buffer_pool_pages_total"].to_i,
|
|
277
|
-
row_lock_waits: status["Innodb_row_lock_waits"].to_i,
|
|
278
|
-
row_lock_time_ms: status["Innodb_row_lock_time"].to_f.round(0),
|
|
279
|
-
},
|
|
280
|
-
queries: {
|
|
281
|
-
questions: questions,
|
|
282
|
-
qps: qps,
|
|
283
|
-
slow_queries: slow_queries,
|
|
284
|
-
tmp_tables: tmp_tables,
|
|
285
|
-
tmp_disk_tables: tmp_disk_tables,
|
|
286
|
-
tmp_disk_pct: tmp_disk_pct,
|
|
287
|
-
select_full_join: status["Select_full_join"].to_i,
|
|
288
|
-
sort_merge_passes: status["Sort_merge_passes"].to_i,
|
|
289
|
-
},
|
|
290
|
-
})
|
|
40
|
+
connection = MysqlGenius::Core::Connection::ActiveRecordAdapter.new(ActiveRecord::Base.connection)
|
|
41
|
+
overview = MysqlGenius::Core::Analysis::ServerOverview.new(connection).call
|
|
42
|
+
render(json: overview)
|
|
291
43
|
rescue => e
|
|
292
44
|
render(json: { error: "Failed to load server overview: #{e.message}" }, status: :unprocessable_entity)
|
|
293
45
|
end
|