and_one 0.1.0 → 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.
- checksums.yaml +4 -4
- data/.githooks/pre-commit +10 -0
- data/CHANGELOG.md +18 -0
- data/README.md +29 -10
- data/lib/and_one/active_job_hook.rb +2 -2
- data/lib/and_one/aggregate.rb +6 -9
- data/lib/and_one/association_resolver.rb +29 -36
- data/lib/and_one/console.rb +7 -9
- data/lib/and_one/detection.rb +3 -3
- data/lib/and_one/detector.rb +12 -12
- data/lib/and_one/dev_toast.rb +149 -0
- data/lib/and_one/dev_ui.rb +49 -51
- data/lib/and_one/fingerprint.rb +1 -1
- data/lib/and_one/formatter.rb +11 -13
- data/lib/and_one/ignore_file.rb +3 -3
- data/lib/and_one/json_formatter.rb +1 -1
- data/lib/and_one/matchers.rb +21 -21
- data/lib/and_one/middleware.rb +39 -1
- data/lib/and_one/railtie.rb +4 -1
- data/lib/and_one/rspec.rb +5 -3
- data/lib/and_one/scan_helper.rb +2 -2
- data/lib/and_one/sidekiq_middleware.rb +2 -2
- data/lib/and_one/version.rb +1 -1
- data/lib/and_one.rb +40 -40
- metadata +7 -5
- data/TODO.md +0 -52
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 51b6c654539d7d462eee3fa1f498e12c67d083032532f70e9068357aaf720813
|
|
4
|
+
data.tar.gz: 9fbd318564ff10362c9e7f24bed9f05654a4db1cb19b2b6f80e76fe7748ec380
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e616668cf8527dd81bdf5d31ab89974ba651a8cdb587f8ff33e5a71fbdae9077474d28da036fc4689ab01fe10a4b7814006c54f25026356606245733c75fab95
|
|
7
|
+
data.tar.gz: e4efa724ed029637706a90007a8e4f16005e15c4278c79fb7d6e1dec4a2c2d8bdab03c6b21ae3da9378b53795e7e4aaa39704bdab878880506e021ecfc075edb
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -e
|
|
3
|
+
|
|
4
|
+
# Get staged Ruby files
|
|
5
|
+
STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACM | grep '\.rb$' || true)
|
|
6
|
+
|
|
7
|
+
if [ -n "$STAGED_FILES" ]; then
|
|
8
|
+
echo "Running RuboCop on staged files..."
|
|
9
|
+
bundle exec rubocop --force-exclusion $STAGED_FILES
|
|
10
|
+
fi
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [0.3.0] - 2026-03-05
|
|
4
|
+
|
|
5
|
+
### Removed
|
|
6
|
+
|
|
7
|
+
- **`aggregate_mode` configuration option** — Deduplication and aggregate tracking are now always-on when AndOne is enabled. The dev toast and dashboard both depend on the aggregate, so having it off was a bug in disguise (detections would show in the toast but never appear on the dashboard). If you had `AndOne.aggregate_mode = true` in an initializer, simply remove the line.
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- N+1 detections now always appear on the `/__and_one` dashboard. Previously, detections would show in the toast notification but not on the dashboard unless `aggregate_mode` was explicitly enabled.
|
|
12
|
+
|
|
13
|
+
## [0.2.0] - 2026-03-02
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
|
|
17
|
+
- **Dev toast notifications** — When an N+1 is detected during a request, a toast notification is injected into the bottom-right corner of the page showing which tables were affected with a link to the `/__and_one` dashboard. Enabled by default in development. Auto-dismisses after 8 seconds; hover to keep open. Only appears on HTML 200 responses. Disable with `AndOne.dev_toast = false`.
|
|
18
|
+
- New `dev_toast` configuration option
|
|
19
|
+
- "Development UI" section in README documenting both the toast and dashboard features
|
|
20
|
+
|
|
3
21
|
## [0.1.0] - 2026-02-27
|
|
4
22
|
|
|
5
23
|
- Initial release
|
data/README.md
CHANGED
|
@@ -14,8 +14,9 @@ AndOne stays completely invisible until it detects an N+1 query — then it tell
|
|
|
14
14
|
- **Auto-raises in test** — N+1s fail your test suite by default
|
|
15
15
|
- **Background job support** — ActiveJob (`around_perform`) and Sidekiq server middleware, with double-scan protection
|
|
16
16
|
- **Ignore file** — `.and_one_ignore` with `gem:`, `path:`, `query:`, and `fingerprint:` rules
|
|
17
|
-
- **
|
|
17
|
+
- **Automatic deduplication** — each unique N+1 is reported once per server session with occurrence counts
|
|
18
18
|
- **Test matchers** — Minitest (`assert_no_n_plus_one`) and RSpec (`expect { }.not_to cause_n_plus_one`)
|
|
19
|
+
- **Dev toast notifications** — in-page toast on every page that triggers an N+1, with a link to the dashboard
|
|
19
20
|
- **Dev UI dashboard** — browse `/__and_one` in development for a live N+1 overview
|
|
20
21
|
- **Rails console integration** — auto-scans in `rails console` and prints warnings inline
|
|
21
22
|
- **Structured JSON logging** — JSON output mode for Datadog, Splunk, and other log aggregation services
|
|
@@ -131,14 +132,9 @@ This is especially useful for **N+1s coming from gems** where you can't add `.in
|
|
|
131
132
|
| `query:some_table` | A specific query pattern should always be ignored |
|
|
132
133
|
| `fingerprint:abc123` | You want to silence one specific detection (shown in output) |
|
|
133
134
|
|
|
134
|
-
##
|
|
135
|
+
## Deduplication
|
|
135
136
|
|
|
136
|
-
In development, the same N+1 can fire on every request, flooding your logs.
|
|
137
|
-
|
|
138
|
-
```ruby
|
|
139
|
-
# config/initializers/and_one.rb
|
|
140
|
-
AndOne.aggregate_mode = true
|
|
141
|
-
```
|
|
137
|
+
In development, the same N+1 can fire on every request, flooding your logs. AndOne automatically deduplicates — each unique pattern is reported only once per server session. Subsequent occurrences are silently counted.
|
|
142
138
|
|
|
143
139
|
You can check the session summary at any time:
|
|
144
140
|
|
|
@@ -148,6 +144,29 @@ AndOne.aggregate.size # number of unique patterns
|
|
|
148
144
|
AndOne.aggregate.reset! # clear and start fresh
|
|
149
145
|
```
|
|
150
146
|
|
|
147
|
+
## Development UI
|
|
148
|
+
|
|
149
|
+
### In-page toast notifications
|
|
150
|
+
|
|
151
|
+
When an N+1 is detected during a request, AndOne injects a small toast notification into the bottom-right corner of the page. The toast shows which tables were affected and links to the full dashboard for details.
|
|
152
|
+
|
|
153
|
+
This is enabled by default in development — no configuration needed. The toast auto-dismisses after 8 seconds, but hovering over it keeps it open.
|
|
154
|
+
|
|
155
|
+
To disable it:
|
|
156
|
+
|
|
157
|
+
```ruby
|
|
158
|
+
# config/initializers/and_one.rb
|
|
159
|
+
AndOne.dev_toast = false
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
The toast only appears on HTML responses with a 200 status, so it won't interfere with API endpoints, redirects, or error pages.
|
|
163
|
+
|
|
164
|
+
### Dashboard
|
|
165
|
+
|
|
166
|
+
Browse `/__and_one` in development for a full overview of every unique N+1 detected in the current server session. The dashboard shows the query, origin, fix location, and suggested `.includes()` call for each detection.
|
|
167
|
+
|
|
168
|
+
Both features work together: the toast gives you immediate feedback on the page you're looking at, and the dashboard link takes you to the full picture.
|
|
169
|
+
|
|
151
170
|
## Test Matchers
|
|
152
171
|
|
|
153
172
|
### Minitest
|
|
@@ -214,8 +233,8 @@ AndOne.configure do |config|
|
|
|
214
233
|
# Minimum repeated queries to trigger (default: 2)
|
|
215
234
|
config.min_n_queries = 3
|
|
216
235
|
|
|
217
|
-
#
|
|
218
|
-
config.
|
|
236
|
+
# In-page toast notifications (default: true in development)
|
|
237
|
+
config.dev_toast = true
|
|
219
238
|
|
|
220
239
|
# Path to ignore file (default: Rails.root/.and_one_ignore)
|
|
221
240
|
config.ignore_file_path = Rails.root.join(".and_one_ignore").to_s
|
data/lib/and_one/aggregate.rb
CHANGED
|
@@ -2,19 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
module AndOne
|
|
4
4
|
# Tracks unique N+1 detections across requests/jobs in a server session.
|
|
5
|
-
#
|
|
5
|
+
# Each unique N+1 (by fingerprint) is only reported once.
|
|
6
6
|
# Subsequent occurrences are silently counted.
|
|
7
7
|
#
|
|
8
|
-
# Usage:
|
|
9
|
-
# AndOne.aggregate_mode = true
|
|
10
|
-
#
|
|
11
8
|
# The aggregate can be queried at any time:
|
|
12
9
|
# AndOne.aggregate.summary # => formatted string
|
|
13
10
|
# AndOne.aggregate.detections # => { fingerprint => { detection:, count:, first_seen_at: } }
|
|
14
11
|
# AndOne.aggregate.reset!
|
|
15
12
|
#
|
|
16
13
|
class Aggregate
|
|
17
|
-
Entry = Struct.new(:detection, :occurrences, :first_seen_at, :last_seen_at
|
|
14
|
+
Entry = Struct.new(:detection, :occurrences, :first_seen_at, :last_seen_at)
|
|
18
15
|
|
|
19
16
|
def initialize
|
|
20
17
|
@mutex = Mutex.new
|
|
@@ -65,19 +62,19 @@ module AndOne
|
|
|
65
62
|
|
|
66
63
|
lines = []
|
|
67
64
|
lines << ""
|
|
68
|
-
lines << "🏀 AndOne Session Summary: #{@entries.size} unique N+1 pattern#{
|
|
69
|
-
lines << "─" * 60
|
|
65
|
+
lines << "🏀 AndOne Session Summary: #{@entries.size} unique N+1 pattern#{"s" if @entries.size != 1}"
|
|
66
|
+
lines << ("─" * 60)
|
|
70
67
|
|
|
71
68
|
@entries.each_with_index do |(fp, entry), i|
|
|
72
69
|
det = entry.detection
|
|
73
|
-
lines << " #{i + 1}) #{det.table_name ||
|
|
70
|
+
lines << " #{i + 1}) #{det.table_name || "unknown"} — #{entry.occurrences} occurrence#{"s" if entry.occurrences != 1}"
|
|
74
71
|
lines << " #{det.sample_query[0, 120]}"
|
|
75
72
|
lines << " origin: #{det.origin_frame}" if det.origin_frame
|
|
76
73
|
lines << " fingerprint: #{fp}"
|
|
77
74
|
lines << ""
|
|
78
75
|
end
|
|
79
76
|
|
|
80
|
-
lines << "─" * 60
|
|
77
|
+
lines << ("─" * 60)
|
|
81
78
|
lines.join("\n")
|
|
82
79
|
end
|
|
83
80
|
end
|
|
@@ -50,7 +50,7 @@ module AndOne
|
|
|
50
50
|
|
|
51
51
|
model = ActiveRecord::Base.descendants.detect do |klass|
|
|
52
52
|
klass.table_name == table_name
|
|
53
|
-
rescue
|
|
53
|
+
rescue StandardError
|
|
54
54
|
false
|
|
55
55
|
end
|
|
56
56
|
|
|
@@ -82,11 +82,11 @@ module AndOne
|
|
|
82
82
|
|
|
83
83
|
klass.reflect_on_all_associations.each do |assoc|
|
|
84
84
|
matched = if effective_key
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
85
|
+
association_matches?(assoc, target_model, effective_key)
|
|
86
|
+
else
|
|
87
|
+
# For through associations, foreign key may not be directly visible
|
|
88
|
+
through_association_matches?(assoc, target_model)
|
|
89
|
+
end
|
|
90
90
|
|
|
91
91
|
next unless matched
|
|
92
92
|
|
|
@@ -98,10 +98,10 @@ module AndOne
|
|
|
98
98
|
fix_hint: build_fix_hint(klass, assoc.name),
|
|
99
99
|
loading_strategy: strategy,
|
|
100
100
|
is_through: assoc.is_a?(ActiveRecord::Reflection::ThroughReflection),
|
|
101
|
-
is_polymorphic: assoc.respond_to?(:options) &&
|
|
101
|
+
is_polymorphic: assoc.respond_to?(:options) && !assoc.options[:as].nil?
|
|
102
102
|
}
|
|
103
103
|
end
|
|
104
|
-
rescue
|
|
104
|
+
rescue StandardError
|
|
105
105
|
next
|
|
106
106
|
end
|
|
107
107
|
|
|
@@ -133,27 +133,25 @@ module AndOne
|
|
|
133
133
|
end
|
|
134
134
|
|
|
135
135
|
def association_matches?(assoc, target_model, foreign_key)
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
assoc.klass == target_model && poly_fk == foreign_key
|
|
148
|
-
else
|
|
149
|
-
assoc.klass == target_model && assoc.foreign_key.to_s == foreign_key
|
|
150
|
-
end
|
|
136
|
+
case assoc
|
|
137
|
+
when ActiveRecord::Reflection::ThroughReflection
|
|
138
|
+
# has_many :through — check if the source association points to our target
|
|
139
|
+
assoc.klass == target_model
|
|
140
|
+
when ActiveRecord::Reflection::HasManyReflection,
|
|
141
|
+
ActiveRecord::Reflection::HasOneReflection
|
|
142
|
+
if assoc.options[:as]
|
|
143
|
+
# Polymorphic: has_many :comments, as: :commentable
|
|
144
|
+
# The foreign key is like "commentable_id" and there's a "commentable_type" column
|
|
145
|
+
poly_fk = "#{assoc.options[:as]}_id"
|
|
146
|
+
assoc.klass == target_model && poly_fk == foreign_key
|
|
151
147
|
else
|
|
152
|
-
|
|
148
|
+
assoc.klass == target_model && assoc.foreign_key.to_s == foreign_key
|
|
153
149
|
end
|
|
154
|
-
|
|
150
|
+
else
|
|
155
151
|
false
|
|
156
152
|
end
|
|
153
|
+
rescue NameError
|
|
154
|
+
false
|
|
157
155
|
end
|
|
158
156
|
|
|
159
157
|
def build_fix_hint(parent_model, association_name)
|
|
@@ -161,13 +159,10 @@ module AndOne
|
|
|
161
159
|
end
|
|
162
160
|
|
|
163
161
|
# Determine the optimal loading strategy based on query patterns
|
|
164
|
-
def loading_strategy(sql,
|
|
162
|
+
def loading_strategy(sql, _association_name)
|
|
165
163
|
# If the query has WHERE conditions on the association table, eager_load
|
|
166
164
|
# is better because it does a LEFT OUTER JOIN allowing WHERE filtering
|
|
167
|
-
if sql =~ /\bWHERE\b/i && sql =~ /\bJOIN\b/i
|
|
168
|
-
:eager_load
|
|
169
|
-
elsif sql =~ /\bWHERE\b.*\b(?:AND|OR)\b/i
|
|
170
|
-
# Complex WHERE — eager_load with JOIN is more efficient
|
|
165
|
+
if sql =~ /\bWHERE\b/i && (sql =~ /\bJOIN\b/i || sql =~ /\b(?:AND|OR)\b/i)
|
|
171
166
|
:eager_load
|
|
172
167
|
else
|
|
173
168
|
# Default: preload is generally faster (separate queries, no JOIN overhead)
|
|
@@ -202,10 +197,10 @@ module AndOne
|
|
|
202
197
|
return nil unless actionable? && @parent_model
|
|
203
198
|
|
|
204
199
|
assoc_type = if @is_through
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
200
|
+
"has_many :#{@association_name}, through: ..."
|
|
201
|
+
else
|
|
202
|
+
"has_many :#{@association_name}"
|
|
203
|
+
end
|
|
209
204
|
|
|
210
205
|
"Or prevent at the model level: `#{assoc_type}, strict_loading: true` in #{@parent_model.name}"
|
|
211
206
|
end
|
|
@@ -219,8 +214,6 @@ module AndOne
|
|
|
219
214
|
"Consider `.eager_load(:#{@association_name})` instead — your query filters on the association, so a JOIN is more efficient"
|
|
220
215
|
when :preload
|
|
221
216
|
"Consider `.preload(:#{@association_name})` — separate queries avoid JOIN overhead for simple loading"
|
|
222
|
-
else
|
|
223
|
-
nil # :includes is the default, no extra hint needed
|
|
224
217
|
end
|
|
225
218
|
end
|
|
226
219
|
end
|
data/lib/and_one/console.rb
CHANGED
|
@@ -49,7 +49,7 @@ module AndOne
|
|
|
49
49
|
|
|
50
50
|
def finish_scan
|
|
51
51
|
AndOne.finish if AndOne.scanning?
|
|
52
|
-
rescue
|
|
52
|
+
rescue StandardError
|
|
53
53
|
# Don't let cleanup errors interrupt the console
|
|
54
54
|
nil
|
|
55
55
|
end
|
|
@@ -79,9 +79,9 @@ module AndOne
|
|
|
79
79
|
|
|
80
80
|
# IRB in Rails 7.1+ uses IRB::Context#evaluate with hooks
|
|
81
81
|
# We hook into the SIGINT-safe eval output via an around_eval approach
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
82
|
+
return unless defined?(::IRB::Context)
|
|
83
|
+
|
|
84
|
+
::IRB::Context.prepend(IrbContextPatch)
|
|
85
85
|
end
|
|
86
86
|
|
|
87
87
|
def remove_irb_hook
|
|
@@ -94,15 +94,13 @@ module AndOne
|
|
|
94
94
|
|
|
95
95
|
@pry_hook_installed = true
|
|
96
96
|
|
|
97
|
-
::Pry.hooks.add_hook(:after_eval, :and_one_console) do |
|
|
97
|
+
::Pry.hooks.add_hook(:after_eval, :and_one_console) do |_result, _pry|
|
|
98
98
|
AndOne::Console.send(:cycle_scan) if AndOne::Console.active?
|
|
99
99
|
end
|
|
100
100
|
end
|
|
101
101
|
|
|
102
102
|
def remove_pry_hook
|
|
103
|
-
if defined?(::Pry) && ::Pry.hooks
|
|
104
|
-
::Pry.hooks.delete_hook(:after_eval, :and_one_console)
|
|
105
|
-
end
|
|
103
|
+
::Pry.hooks.delete_hook(:after_eval, :and_one_console) if defined?(::Pry) && ::Pry.hooks
|
|
106
104
|
@pry_hook_installed = false
|
|
107
105
|
end
|
|
108
106
|
|
|
@@ -119,7 +117,7 @@ module AndOne
|
|
|
119
117
|
result = super
|
|
120
118
|
AndOne::Console.send(:cycle_scan) if AndOne::Console.active?
|
|
121
119
|
result
|
|
122
|
-
rescue
|
|
120
|
+
rescue StandardError
|
|
123
121
|
AndOne::Console.send(:cycle_scan) if AndOne::Console.active?
|
|
124
122
|
raise
|
|
125
123
|
end
|
data/lib/and_one/detection.rb
CHANGED
data/lib/and_one/detector.rb
CHANGED
|
@@ -5,8 +5,8 @@ module AndOne
|
|
|
5
5
|
# Each instance tracks queries for a single request/block scope.
|
|
6
6
|
class Detector
|
|
7
7
|
DEFAULT_ALLOW_LIST = [
|
|
8
|
-
/
|
|
9
|
-
/
|
|
8
|
+
%r{active_record/relation.*preload_associations},
|
|
9
|
+
%r{active_record/validations/uniqueness}
|
|
10
10
|
].freeze
|
|
11
11
|
|
|
12
12
|
attr_reader :detections
|
|
@@ -58,7 +58,7 @@ module AndOne
|
|
|
58
58
|
name = payload[:name]
|
|
59
59
|
|
|
60
60
|
next if name == "SCHEMA"
|
|
61
|
-
next
|
|
61
|
+
next unless sql.include?("SELECT")
|
|
62
62
|
next if payload[:cached]
|
|
63
63
|
next if current_detector.send(:ignored?, sql)
|
|
64
64
|
|
|
@@ -79,13 +79,13 @@ module AndOne
|
|
|
79
79
|
@query_holder[location_key] << sql
|
|
80
80
|
|
|
81
81
|
# Only store caller on the second occurrence to save memory
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
82
|
+
return unless @query_counter[location_key] >= 2
|
|
83
|
+
|
|
84
|
+
@query_callers[location_key] = locations
|
|
85
|
+
@query_metadata[location_key] ||= {
|
|
86
|
+
connection_adapter: adapter_name,
|
|
87
|
+
type_casted_binds: payload[:type_casted_binds]
|
|
88
|
+
}
|
|
89
89
|
end
|
|
90
90
|
|
|
91
91
|
def location_fingerprint(locations)
|
|
@@ -99,7 +99,7 @@ module AndOne
|
|
|
99
99
|
|
|
100
100
|
def adapter_name
|
|
101
101
|
ActiveRecord::Base.connection_db_config.adapter
|
|
102
|
-
rescue
|
|
102
|
+
rescue StandardError
|
|
103
103
|
"unknown"
|
|
104
104
|
end
|
|
105
105
|
|
|
@@ -135,7 +135,7 @@ module AndOne
|
|
|
135
135
|
end
|
|
136
136
|
|
|
137
137
|
def ignored?(sql)
|
|
138
|
-
@ignore_queries.any? { |pattern| pattern
|
|
138
|
+
@ignore_queries.any? { |pattern| pattern.match?(sql) }
|
|
139
139
|
end
|
|
140
140
|
end
|
|
141
141
|
end
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AndOne
|
|
4
|
+
# Injects a small toast notification into HTML responses when N+1 queries
|
|
5
|
+
# are detected during the request. Links to the DevUI dashboard for details.
|
|
6
|
+
#
|
|
7
|
+
# Activated automatically by the Railtie in development when `dev_toast` is on,
|
|
8
|
+
# or can be enabled manually:
|
|
9
|
+
# AndOne.dev_toast = true
|
|
10
|
+
#
|
|
11
|
+
# The toast appears as a fixed-position badge in the bottom-right corner
|
|
12
|
+
# and auto-dismisses after 8 seconds (click to keep open).
|
|
13
|
+
module DevToast
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
# Injects toast HTML/JS/CSS before </body> in an HTML response.
|
|
17
|
+
# Returns the modified body string, or the original if not injectable.
|
|
18
|
+
def inject(body_string, detections)
|
|
19
|
+
return body_string if detections.nil? || detections.empty?
|
|
20
|
+
return body_string unless body_string.include?("</body>")
|
|
21
|
+
|
|
22
|
+
toast_html = render_toast(detections)
|
|
23
|
+
body_string.sub("</body>", "#{toast_html}\n</body>")
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def render_toast(detections)
|
|
27
|
+
count = detections.size
|
|
28
|
+
label = "N+1 quer#{count == 1 ? "y" : "ies"}"
|
|
29
|
+
|
|
30
|
+
summaries = detections.map do |d|
|
|
31
|
+
table = escape(d.table_name || "unknown")
|
|
32
|
+
"#{d.count}x <code>#{table}</code>"
|
|
33
|
+
end.first(5)
|
|
34
|
+
|
|
35
|
+
extra = count > 5 ? "<div class=\"and-one-toast-extra\">...and #{count - 5} more</div>" : ""
|
|
36
|
+
|
|
37
|
+
<<~HTML
|
|
38
|
+
<div id="and-one-toast" class="and-one-toast" role="status" aria-live="polite">
|
|
39
|
+
<div class="and-one-toast-header">
|
|
40
|
+
<span class="and-one-toast-icon">🏀</span>
|
|
41
|
+
<strong>AndOne:</strong> #{count} #{label} detected
|
|
42
|
+
<button class="and-one-toast-close" onclick="document.getElementById('and-one-toast').remove()" aria-label="Dismiss">×</button>
|
|
43
|
+
</div>
|
|
44
|
+
<div class="and-one-toast-body">
|
|
45
|
+
#{summaries.join("<br>")}
|
|
46
|
+
#{extra}
|
|
47
|
+
</div>
|
|
48
|
+
<a class="and-one-toast-link" href="#{DevUI::MOUNT_PATH}">View Dashboard →</a>
|
|
49
|
+
</div>
|
|
50
|
+
<style>
|
|
51
|
+
.and-one-toast {
|
|
52
|
+
position: fixed;
|
|
53
|
+
bottom: 1rem;
|
|
54
|
+
right: 1rem;
|
|
55
|
+
z-index: 999999;
|
|
56
|
+
background: #1a1a2e;
|
|
57
|
+
color: #e0e0e0;
|
|
58
|
+
border: 2px solid #ff6b6b;
|
|
59
|
+
border-radius: 8px;
|
|
60
|
+
padding: 0;
|
|
61
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, monospace;
|
|
62
|
+
font-size: 13px;
|
|
63
|
+
max-width: 380px;
|
|
64
|
+
min-width: 260px;
|
|
65
|
+
box-shadow: 0 4px 24px rgba(0,0,0,0.4);
|
|
66
|
+
animation: and-one-slide-in 0.3s ease-out;
|
|
67
|
+
transition: opacity 0.3s ease;
|
|
68
|
+
}
|
|
69
|
+
.and-one-toast-header {
|
|
70
|
+
display: flex;
|
|
71
|
+
align-items: center;
|
|
72
|
+
gap: 0.4rem;
|
|
73
|
+
padding: 0.5rem 0.75rem;
|
|
74
|
+
background: #16213e;
|
|
75
|
+
border-radius: 6px 6px 0 0;
|
|
76
|
+
border-bottom: 1px solid #2a2a4a;
|
|
77
|
+
color: #ff6b6b;
|
|
78
|
+
font-size: 13px;
|
|
79
|
+
}
|
|
80
|
+
.and-one-toast-icon { font-size: 16px; }
|
|
81
|
+
.and-one-toast-close {
|
|
82
|
+
margin-left: auto;
|
|
83
|
+
background: none;
|
|
84
|
+
border: none;
|
|
85
|
+
color: #888;
|
|
86
|
+
font-size: 18px;
|
|
87
|
+
cursor: pointer;
|
|
88
|
+
padding: 0 0.25rem;
|
|
89
|
+
line-height: 1;
|
|
90
|
+
}
|
|
91
|
+
.and-one-toast-close:hover { color: #ff6b6b; }
|
|
92
|
+
.and-one-toast-body {
|
|
93
|
+
padding: 0.5rem 0.75rem;
|
|
94
|
+
line-height: 1.5;
|
|
95
|
+
color: #ccc;
|
|
96
|
+
}
|
|
97
|
+
.and-one-toast-body code {
|
|
98
|
+
background: #16213e;
|
|
99
|
+
padding: 0.1rem 0.3rem;
|
|
100
|
+
border-radius: 3px;
|
|
101
|
+
font-size: 12px;
|
|
102
|
+
color: #ffd93d;
|
|
103
|
+
}
|
|
104
|
+
.and-one-toast-extra {
|
|
105
|
+
color: #888;
|
|
106
|
+
font-size: 12px;
|
|
107
|
+
margin-top: 0.25rem;
|
|
108
|
+
}
|
|
109
|
+
.and-one-toast-link {
|
|
110
|
+
display: block;
|
|
111
|
+
padding: 0.5rem 0.75rem;
|
|
112
|
+
color: #a8d8ea;
|
|
113
|
+
text-decoration: none;
|
|
114
|
+
font-size: 12px;
|
|
115
|
+
border-top: 1px solid #2a2a4a;
|
|
116
|
+
}
|
|
117
|
+
.and-one-toast-link:hover {
|
|
118
|
+
background: #16213e;
|
|
119
|
+
border-radius: 0 0 6px 6px;
|
|
120
|
+
text-decoration: underline;
|
|
121
|
+
}
|
|
122
|
+
@keyframes and-one-slide-in {
|
|
123
|
+
from { transform: translateY(1rem); opacity: 0; }
|
|
124
|
+
to { transform: translateY(0); opacity: 1; }
|
|
125
|
+
}
|
|
126
|
+
</style>
|
|
127
|
+
<script>
|
|
128
|
+
(function() {
|
|
129
|
+
var toast = document.getElementById('and-one-toast');
|
|
130
|
+
if (!toast) return;
|
|
131
|
+
var timer = setTimeout(function() {
|
|
132
|
+
toast.style.opacity = '0';
|
|
133
|
+
setTimeout(function() { toast.remove(); }, 300);
|
|
134
|
+
}, 8000);
|
|
135
|
+
toast.addEventListener('mouseenter', function() { clearTimeout(timer); });
|
|
136
|
+
})();
|
|
137
|
+
</script>
|
|
138
|
+
HTML
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def escape(text)
|
|
142
|
+
text.to_s
|
|
143
|
+
.gsub("&", "&")
|
|
144
|
+
.gsub("<", "<")
|
|
145
|
+
.gsub(">", ">")
|
|
146
|
+
.gsub('"', """)
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|