n_plus_insight 0.1.0 → 0.1.2

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: b64b2190f32d5bd69dc972654ccb46add8d3a2a4bacc9938a3e0bf5b7035004f
4
- data.tar.gz: 9af66e38cdbe1676aece5a7cf193f6eae284589163b845e58adf9bef36a5ef66
3
+ metadata.gz: b8ae9f6ffeef6402349d63d9ceb1797a71aeb567e18383019b95e455b32d9b1b
4
+ data.tar.gz: 86c59f125447834906a627ed2ace73cae60b171ecf4d3ef7183e7864d919cc0f
5
5
  SHA512:
6
- metadata.gz: e6ed0f4ad2dacf35a5e9c52ba9d7597c6d536d05f2930d1798082da89bd811d4e6bb7b63a60b1f9f10608c63b2fbbc982b799038410acf5b46d2b5224c862170
7
- data.tar.gz: f9c71aa555d723bb1b2d109e572c8f49458c361196de2ee48d6f734158ba55bafba84d4ef0fe418aea10559f994ef751eb3407ac29d4035a0923d54e56ae837b
6
+ metadata.gz: 137231015fad7ceee3a2658aafb9ab7accea46c44feea299e9d17eb4e5f960e65b4792102592556fa9cff7bfda956cba9f6e5c04c0b02ab641f4e6e0f4541b5c
7
+ data.tar.gz: f24bbbb97328f57c943a466c586494fbf94147349c99fb209c1232aa398e7058abe6d7c0b9dc5024e0a594b6388a0c9b6ac8999115e45054acf6961407b923c8
data/CHANGELOG.md CHANGED
@@ -3,9 +3,15 @@
3
3
  ## 0.1.0 - 2026-07-28
4
4
 
5
5
  - Detect repeated Active Record query shapes within a request.
6
+ - Combine multiple repeated query shapes attributed to the same source line.
6
7
  - Identify relevant application source files, line numbers, and code excerpts.
7
- - Visualize affected models and associations.
8
- - Recommend eager-loading and strict-loading remediations.
8
+ - Visualize multi-model associations as nested trees.
9
+ - Recommend combined eager-loading and strict-loading remediations for the
10
+ complete association tree.
9
11
  - Provide an optional on-page status indicator and findings panel.
10
12
  - Provide a full mounted findings dashboard.
13
+ - Allow operators to clear stored findings from the dashboard.
14
+ - Roll off the oldest dashboard findings according to `max_events`.
15
+ - Publish canonical GitHub, documentation, changelog, and issue-tracker
16
+ metadata.
11
17
  - Support environment-neutral activation and deployment.
data/LICENSE.txt CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2026 NPlusInsight contributors
3
+ Copyright (c) 2026 Jory Leech
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
data/README.md CHANGED
@@ -1,70 +1,367 @@
1
1
  # NPlusInsight
2
2
 
3
- NPlusInsight is a Rails gem that detects repeated query
4
- shapes within a request and turns them into a useful debugging report:
3
+ [![Ruby 3.1+](https://img.shields.io/badge/Ruby-3.1%2B-CC342D?logo=ruby&logoColor=white)](https://www.ruby-lang.org/)
4
+ [![Rails 7–8](https://img.shields.io/badge/Rails-7.0%E2%80%938.x-D30001?logo=rubyonrails&logoColor=white)](https://rubyonrails.org/)
5
+ [![License: MIT](https://img.shields.io/badge/License-MIT-2563EB.svg)](LICENSE.txt)
5
6
 
6
- - the exact application file and line that issued the query;
7
- - a model/association graph for the tables involved;
8
- - normalized SQL, repetition count, and total query time;
9
- - suggested `includes` and `strict_loading` fixes.
7
+ [Source code](https://github.com/joryleech/NPlusInsight) ·
8
+ [Issue tracker](https://github.com/joryleech/NPlusInsight/issues) ·
9
+ [Changelog](CHANGELOG.md)
10
10
 
11
- ## Install
11
+ NPlusInsight detects N+1 queries in Ruby on Rails applications and turns them
12
+ into actionable, source-level diagnostics.
12
13
 
13
- Add the gem in every environment where you may enable it:
14
+ For each finding, NPlusInsight shows:
15
+
16
+ - the application file, line number, and surrounding source code;
17
+ - the repeated SQL shape, query count, and cumulative duration;
18
+ - the Active Record models and associations involved;
19
+ - suggested eager-loading and strict-loading remediations;
20
+ - an optional on-page alert and a mounted findings dashboard.
21
+
22
+ NPlusInsight is environment-neutral. Enable it anywhere you need diagnostics,
23
+ including development, test, staging, or a controlled production environment.
24
+
25
+ ## Table of contents
26
+
27
+ - [Why NPlusInsight?](#why-nplusinsight)
28
+ - [Screenshots](#screenshots)
29
+ - [Requirements](#requirements)
30
+ - [Installation](#installation)
31
+ - [Quick start](#quick-start)
32
+ - [Configuration](#configuration)
33
+ - [Dashboard and on-page inspector](#dashboard-and-on-page-inspector)
34
+ - [How detection works](#how-detection-works)
35
+ - [Security and privacy](#security-and-privacy)
36
+ - [Operational considerations](#operational-considerations)
37
+ - [CI and test environments](#ci-and-test-environments)
38
+ - [Troubleshooting](#troubleshooting)
39
+ - [Development](#development)
40
+ - [Contributing](#contributing)
41
+ - [Releasing](#releasing)
42
+ - [License](#license)
43
+
44
+ ## Why NPlusInsight?
45
+
46
+ Rails logs make repeated queries visible, but they do not always make the cause
47
+ obvious. NPlusInsight connects runtime query evidence to the application code
48
+ that triggered it, then presents the affected model relationship and a
49
+ candidate fix in one place.
50
+
51
+ The gem is designed to keep the feedback loop short:
52
+
53
+ 1. Exercise an application page.
54
+ 2. See the on-page alert when repeated query patterns are detected.
55
+ 3. Open the finding to inspect the responsible source line and model graph.
56
+ 4. Apply eager loading.
57
+ 5. Reload the page and confirm that the finding is gone.
58
+
59
+ ## Screenshots
60
+
61
+ ### On-page alert
62
+
63
+ The corner indicator turns red and displays the number of N+1 query patterns
64
+ detected while rendering the current page.
65
+
66
+ ![NPlusInsight on-page N+1 alert](docs/images/on-page-alert.png)
67
+
68
+ ### On-page findings inspector
69
+
70
+ Open the indicator to inspect relevant source lines, affected models, and
71
+ remediation suggestions without leaving the page.
72
+
73
+ ![NPlusInsight grouped query patterns and multi-model tree in the on-page inspector](docs/images/on-page-popout-modeled.png)
74
+
75
+ ### Mounted findings dashboard
76
+
77
+ The mounted dashboard collects recent findings across requests for deeper
78
+ inspection.
79
+
80
+ ![NPlusInsight full findings dashboard](docs/images/full-dashboard.png)
81
+
82
+ ## Requirements
83
+
84
+ | Dependency | Supported versions |
85
+ | --- | --- |
86
+ | Ruby | 3.1 or newer |
87
+ | Ruby on Rails | 7.0 through 8.x |
88
+ | Active Record | Provided by the Rails application |
89
+
90
+ ## Installation
91
+
92
+ Add NPlusInsight to the environments where it must be available:
14
93
 
15
94
  ```ruby
95
+ # Gemfile
16
96
  gem "n_plus_insight"
17
97
  ```
18
98
 
19
- Run `bundle install`, then configure NPlusInsight for the environments where it
20
- should be active.
99
+ Install dependencies:
100
+
101
+ ```sh
102
+ bundle install
103
+ ```
104
+
105
+ The Rails engine registers its middleware and mounts its dashboard
106
+ automatically. No route declaration is required.
21
107
 
22
- Then configure activation from the environment rather than tying it to a Rails
23
- environment name:
108
+ ## Quick start
109
+
110
+ Enable NPlusInsight in the Rails environment where you want to use it:
111
+
112
+ ```ruby
113
+ # config/environments/development.rb
114
+ NPlusInsight.configure do |config|
115
+ config.enabled = true
116
+ end
117
+ ```
118
+
119
+ Keep shared settings in an initializer:
120
+
121
+ ```ruby
122
+ # config/initializers/n_plus_insight.rb
123
+ NPlusInsight.configure do |config|
124
+ config.minimum_repetitions = 2
125
+ config.max_events = 100
126
+ config.mount_path = "/n_plus_insight"
127
+ config.on_page = true
128
+ config.raise_on_detection = false
129
+ end
130
+ ```
131
+
132
+ Start Rails, exercise the application, and visit:
133
+
134
+ ```text
135
+ http://localhost:3000/n_plus_insight
136
+ ```
137
+
138
+ ### Environment-variable activation
139
+
140
+ For containerized or remotely managed deployments, activation can be driven by
141
+ an environment variable:
24
142
 
25
143
  ```ruby
26
144
  # config/initializers/n_plus_insight.rb
27
- if Rails.env.development?
28
- NPlusInsight.configure do |config|
29
- config.minimum_repetitions = 2
30
- config.mount_path = "/n_plus_insight"
31
- config.on_page = true
32
- config.max_events = 100
33
- config.raise_on_detection = false
34
- end
145
+ NPlusInsight.configure do |config|
146
+ config.enabled = ENV.fetch("NPLUS_INSIGHT_ENABLED", "false")
147
+ .match?(/\A(?:1|true|yes|on)\z/i)
148
+ end
149
+ ```
150
+
151
+ The gem uses the same `NPLUS_INSIGHT_ENABLED` parsing when `enabled` is not set
152
+ explicitly. Its default value is `false`.
153
+
154
+ ## Configuration
155
+
156
+ | Option | Default | Description |
157
+ | --- | --- | --- |
158
+ | `enabled` | `NPLUS_INSIGHT_ENABLED`, otherwise `false` | Enables request-level query collection and analysis. |
159
+ | `minimum_repetitions` | `2` | Number of matching query shapes required to create a finding. |
160
+ | `max_events` | `100` | Maximum number of findings retained for the dashboard. The oldest findings are removed first. |
161
+ | `mount_path` | `"/n_plus_insight"` | Path used for the mounted dashboard and overlay assets. Configure this before the application finishes booting. |
162
+ | `on_page` | `true` | Injects the status indicator and findings inspector into HTML responses. |
163
+ | `raise_on_detection` | `false` | Raises when a request produces at least one finding. Useful in controlled tests or CI. |
164
+ | `ignore_paths` | Asset and Active Storage paths | Array of regular expressions for request paths that should not be analyzed. |
165
+ | `ignore_sql` | Transactions and Rails metadata | Array of regular expressions for SQL statements that should be ignored. |
166
+
167
+ ### Ignoring application paths
168
+
169
+ ```ruby
170
+ NPlusInsight.configure do |config|
171
+ config.ignore_paths += [
172
+ %r{\A/health\z},
173
+ %r{\A/metrics\z}
174
+ ]
35
175
  end
36
176
  ```
37
- With `on_page` enabled, every HTML page also gets a small status circle in the
38
- bottom-right corner. It turns red and shows a count when the current request
39
- contains N+1 query patterns. Select it to inspect source lines, the affected
40
- model graph, and suggested fixes without leaving the page. Set
41
- `config.on_page = false` to remove the on-page tool while retaining collection
42
- and the full dashboard.
177
+
178
+ ### Ignoring known query patterns
179
+
180
+ ```ruby
181
+ NPlusInsight.configure do |config|
182
+ config.ignore_sql += [
183
+ /FROM "audit_events"/i
184
+ ]
185
+ end
186
+ ```
187
+
188
+ ## Dashboard and on-page inspector
189
+
190
+ ### On-page inspector
191
+
192
+ When `on_page` is enabled, NPlusInsight injects a small status indicator into
193
+ HTML responses:
194
+
195
+ - green means no repeated query pattern was detected for that request;
196
+ - red means one or more patterns were detected;
197
+ - the badge shows the number of findings;
198
+ - selecting the indicator opens source, model, and remediation details.
199
+
200
+ Set `config.on_page = false` to disable response injection while continuing to
201
+ collect findings for the mounted dashboard.
202
+
203
+ ### Mounted dashboard
204
+
205
+ The dashboard is available at `mount_path`, which defaults to
206
+ `/n_plus_insight`. It lists the most recent findings stored by the current Rails
207
+ process and links to a detailed report for each finding. Use **Clear** to remove
208
+ all stored findings. The dashboard retains at most `max_events`
209
+ findings, rolling off the oldest finding whenever the configured limit is
210
+ exceeded.
43
211
 
44
212
  ## How detection works
45
213
 
46
- The gem listens to `sql.active_record` events during each web request. It
47
- removes literal values from SQL and groups matching shapes. A shape repeated at
48
- least `minimum_repetitions` times is reported. Framework, gem, schema, cached,
49
- transaction, and asset requests are excluded.
214
+ NPlusInsight subscribes to `sql.active_record` notifications while a web
215
+ request is being processed.
216
+
217
+ For each uncached `SELECT` query, it:
50
218
 
51
- This deliberately reports evidence rather than patching source automatically.
52
- Suggested fixes should be reviewed because scopes, polymorphic associations,
53
- and intentionally lazy-loaded data can change the best eager-loading strategy.
219
+ 1. captures the first relevant application stack frame;
220
+ 2. extracts tables referenced by `FROM` and `JOIN`;
221
+ 3. replaces literal strings and numbers with placeholders;
222
+ 4. groups queries with the same normalized shape;
223
+ 5. keeps shapes meeting `minimum_repetitions`;
224
+ 6. combines repeated shapes attributed to the same application source line into
225
+ one finding;
226
+ 7. inspects Active Record reflections to infer every relevant model association;
227
+ 8. renders the affected associations as a model tree;
228
+ 9. generates combined eager-loading and strict-loading suggestions for the
229
+ complete tree.
54
230
 
55
- ## CI mode
231
+ For example, if one serializer line lazily loads both `post.comments` and
232
+ `post.likes`, NPlusInsight reports one finding with two query patterns, a
233
+ `Post` tree branching to `Comment` and `Like`, and a combined remediation such
234
+ as `Post.includes(:comments, :likes)`. Deeper paths are represented with nested
235
+ `includes`, such as `User.includes({ posts: [:comments, :likes] })`.
56
236
 
57
- To turn findings into failures in a test or staging environment:
237
+ Cached queries, schema activity, Rails metadata queries, transaction statements,
238
+ and ignored requests are excluded.
239
+
240
+ NPlusInsight reports evidence and candidate remediations; it does not modify
241
+ application source code automatically.
242
+
243
+ ## Security and privacy
244
+
245
+ Findings can contain:
246
+
247
+ - local source paths and source-code excerpts;
248
+ - normalized SQL shapes;
249
+ - controller paths and request methods;
250
+ - model and association names.
251
+
252
+ The dashboard is intentionally not restricted to a specific Rails environment.
253
+ If NPlusInsight is enabled on an internet-facing deployment, protect
254
+ `mount_path` with application authentication, a VPN, network policy, or
255
+ reverse-proxy access controls.
256
+
257
+ NPlusInsight stores findings in process memory. It does not persist findings to
258
+ a database or transmit them to an external service.
259
+
260
+ ## Operational considerations
261
+
262
+ - **Process-local storage:** each Rails process maintains its own findings.
263
+ Findings are not shared between Puma workers, containers, or hosts.
264
+ - **Ephemeral history:** findings are cleared when the Rails process restarts.
265
+ - **HTML buffering:** the on-page inspector buffers eligible HTML responses so
266
+ it can inject the overlay before `</body>`.
267
+ - **Non-HTML responses:** JSON, files, streams, and other response types are not
268
+ modified.
269
+ - **Compressed responses:** responses already carrying `Content-Encoding` are
270
+ not modified by the on-page inspector.
271
+ - **Heuristic detection:** repeated queries can be intentional. Review each
272
+ finding before changing loading behavior.
273
+ - **Heuristic suggestions:** polymorphic relationships, custom scopes, and
274
+ indirect associations may require a different preload strategy than the
275
+ suggested code.
276
+
277
+ For high-traffic production deployments, begin with `on_page = false`, use a
278
+ small `max_events`, and evaluate overhead under representative load.
279
+
280
+ ## CI and test environments
281
+
282
+ Enable `raise_on_detection` to turn a finding into a request failure:
58
283
 
59
284
  ```ruby
60
- config.raise_on_detection = true
285
+ # config/environments/test.rb
286
+ NPlusInsight.configure do |config|
287
+ config.enabled = true
288
+ config.on_page = false
289
+ config.raise_on_detection = true
290
+ end
61
291
  ```
62
292
 
63
- The dashboard is not restricted to a particular Rails environment. If it is
64
- enabled on an internet-facing deployment, protect `/n_plus_insight` with your
65
- application's authentication, authorization, VPN, or reverse-proxy access
66
- controls because findings contain source paths, code excerpts, and SQL shapes.
293
+ Use this mode selectively. Test setup, factories, and intentionally repeated
294
+ queries can otherwise produce noisy failures.
295
+
296
+ ## Troubleshooting
297
+
298
+ ### The indicator does not appear
299
+
300
+ Confirm that:
301
+
302
+ - `config.enabled` and `config.on_page` are both `true`;
303
+ - the response has a `text/html` content type;
304
+ - the response is not already compressed;
305
+ - the request path does not match `ignore_paths`;
306
+ - the application's Content Security Policy permits same-origin scripts and
307
+ styles.
308
+
309
+ ### The indicator is green, but repeated queries appear in the Rails log
310
+
311
+ Check whether the queries:
312
+
313
+ - occur fewer times than `minimum_repetitions`;
314
+ - are served from the Active Record query cache;
315
+ - are not `SELECT` statements;
316
+ - match an `ignore_sql` pattern;
317
+ - execute outside the lifecycle of an HTTP request.
318
+
319
+ ### The dashboard is empty in a multi-process deployment
320
+
321
+ The store is process-local. A request served by one worker is not visible in a
322
+ dashboard request served by another worker.
323
+
324
+ ### Findings point to the correct line but suggest the wrong association
325
+
326
+ Association inference is heuristic. Use the source location, SQL shape, and
327
+ model graph as evidence, then apply the eager-loading strategy appropriate for
328
+ the relation that feeds that code.
329
+
330
+ ## Development
331
+
332
+ Install dependencies and run the test suite:
333
+
334
+ ```sh
335
+ bundle install
336
+ bundle exec rake test
337
+ ```
338
+
339
+ Build the gem locally:
340
+
341
+ ```sh
342
+ bundle exec rake build
343
+ ```
344
+
345
+ The built package is written to `pkg/`.
346
+
347
+ ## Contributing
348
+
349
+ [Bug reports](https://github.com/joryleech/NPlusInsight/issues/new/choose) and
350
+ [pull requests](https://github.com/joryleech/NPlusInsight/pulls) are welcome.
351
+
352
+ When proposing a change:
353
+
354
+ 1. include a focused reproduction or test;
355
+ 2. keep detection behavior deterministic;
356
+ 3. preserve environment-neutral configuration;
357
+ 4. verify both the mounted dashboard and on-page inspector when changing UI;
358
+ 5. run `bundle exec rake test`.
359
+
360
+ Please avoid including production SQL, source code, credentials, or other
361
+ sensitive application data in public issues.
67
362
 
68
363
  ## License
69
364
 
70
- MIT
365
+ NPlusInsight is maintained by
366
+ [Jory Leech](https://github.com/joryleech) and available under the
367
+ [MIT License](LICENSE.txt).
@@ -100,14 +100,22 @@
100
100
  #n1v-root .n1v-heading { margin: 0 0 8px !important; color: var(--n1v-text) !important; font: 650 13px/1.3 ui-sans-serif, system-ui, sans-serif !important; }
101
101
  #n1v-root .n1v-location { margin: 0 0 8px !important; color: var(--n1v-muted) !important; overflow-wrap: anywhere !important; font: 12px/1.4 ui-monospace, SFMono-Regular, Consolas, monospace !important; }
102
102
  #n1v-root .n1v-code { display: block !important; max-width: 100% !important; margin: 0 !important; padding: 10px 0 !important; overflow: auto !important; border-radius: 8px !important; background: var(--n1v-code) !important; color: #e4e7ec !important; font: 12px/1.5 ui-monospace, SFMono-Regular, Consolas, monospace !important; white-space: pre !important; }
103
+ #n1v-root .n1v-code code { display: block !important; margin: 0 !important; padding: 0 12px !important; border: 0 !important; border-radius: 0 !important; background: transparent !important; color: inherit !important; font: inherit !important; white-space: inherit !important; }
103
104
  #n1v-root .n1v-line { display: grid !important; grid-template-columns: 38px minmax(0, 1fr) !important; min-width: max-content !important; padding: 0 12px !important; }
104
105
  #n1v-root .n1v-line.n1v-active { background: rgba(217, 45, 32, .28) !important; }
105
106
  #n1v-root .n1v-line-number { color: #98a2b3 !important; user-select: none !important; }
106
- #n1v-root .n1v-graph { display: flex !important; align-items: center !important; gap: 7px !important; min-height: 74px !important; overflow-x: auto !important; padding: 8px 0 !important; }
107
- #n1v-root .n1v-node { flex: 0 0 auto !important; min-width: 130px !important; padding: 10px 12px !important; border: 1px solid var(--n1v-accent) !important; border-radius: 9px !important; background: var(--n1v-accent-soft) !important; color: var(--n1v-text) !important; text-align: center !important; }
107
+ #n1v-root .n1v-graph { min-height: 74px !important; overflow: auto !important; padding: 4px 0 !important; }
108
+ #n1v-root .n1v-tree { margin: 0 !important; padding: 0 !important; list-style: none !important; }
109
+ #n1v-root .n1v-tree .n1v-tree { margin-left: 18px !important; padding-left: 18px !important; border-left: 1px solid var(--n1v-border) !important; }
110
+ #n1v-root .n1v-tree-item { position: relative !important; margin: 9px 0 !important; list-style: none !important; }
111
+ #n1v-root .n1v-tree .n1v-tree > .n1v-tree-item::before { content: "" !important; position: absolute !important; left: -18px !important; top: 27px !important; width: 18px !important; border-top: 1px solid var(--n1v-border) !important; }
112
+ #n1v-root .n1v-node { display: inline-grid !important; min-width: 130px !important; padding: 10px 12px !important; border: 1px solid var(--n1v-accent) !important; border-radius: 9px !important; background: var(--n1v-accent-soft) !important; color: var(--n1v-text) !important; text-align: center !important; }
108
113
  #n1v-root .n1v-node strong, #n1v-root .n1v-node small { display: block !important; color: inherit !important; }
109
114
  #n1v-root .n1v-node small { margin-top: 2px !important; color: var(--n1v-muted) !important; font-size: 11px !important; }
110
- #n1v-root .n1v-edge { flex: 0 0 auto !important; color: var(--n1v-muted) !important; text-align: center !important; font-size: 11px !important; }
115
+ #n1v-root .n1v-edge { display: block !important; margin: 0 0 4px !important; color: var(--n1v-muted) !important; font-size: 11px !important; }
116
+ #n1v-root .n1v-query { margin: 0 !important; padding: 8px 0 !important; border-top: 1px solid var(--n1v-border) !important; }
117
+ #n1v-root .n1v-query:first-of-type { padding-top: 0 !important; border-top: 0 !important; }
118
+ #n1v-root .n1v-query-meta { margin: 0 0 5px !important; color: var(--n1v-muted) !important; font: 11px/1.4 ui-sans-serif, system-ui, sans-serif !important; }
111
119
  #n1v-root .n1v-fix { margin: 0 !important; padding: 12px 0 !important; border-top: 1px solid var(--n1v-border) !important; }
112
120
  #n1v-root .n1v-fix:first-of-type { padding-top: 0 !important; border-top: 0 !important; }
113
121
  #n1v-root .n1v-fix-title { margin: 0 0 4px !important; color: var(--n1v-text) !important; font: 650 13px/1.4 ui-sans-serif, system-ui, sans-serif !important; }
@@ -23,10 +23,33 @@
23
23
  }
24
24
  }
25
25
 
26
+ function queryGroupsFor(finding) {
27
+ if ((finding.query_groups || []).length) return finding.query_groups;
28
+ return [{
29
+ sql: finding.sql,
30
+ query_count: finding.query_count,
31
+ total_ms: finding.total_ms,
32
+ tables: []
33
+ }];
34
+ }
35
+
26
36
  var findings = decodePayload(root.dataset.payload || "");
37
+ var initialFindingIndex = findings.reduce(function (bestIndex, finding, index) {
38
+ var best = findings[bestIndex];
39
+ var patternCount = queryGroupsFor(finding).length;
40
+ var bestPatternCount = best ? queryGroupsFor(best).length : -1;
41
+
42
+ if (patternCount > bestPatternCount) return index;
43
+ if (patternCount === bestPatternCount && finding.query_count > best.query_count) return index;
44
+ return bestIndex;
45
+ }, 0);
46
+ var patternCount = findings.reduce(function (total, finding) {
47
+ return total + queryGroupsFor(finding).length;
48
+ }, 0);
49
+
27
50
  var launcher = element("button", "n1v-launcher" + (findings.length ? " n1v-has-alert" : ""), "N+1");
28
51
  launcher.type = "button";
29
- launcher.setAttribute("aria-label", findings.length ? "Open NPlusInsight: " + findings.length + " detected" : "Open NPlusInsight: no detections");
52
+ launcher.setAttribute("aria-label", findings.length ? "Open NPlusInsight: " + findings.length + " affected source locations" : "Open NPlusInsight: no detections");
30
53
  launcher.setAttribute("aria-expanded", "false");
31
54
 
32
55
  if (findings.length) {
@@ -44,7 +67,14 @@
44
67
  var header = element("div", "n1v-header");
45
68
  var headingWrap = element("div");
46
69
  headingWrap.appendChild(element("h2", "n1v-title", findings.length ? "N+1 detected" : "No N+1 queries"));
47
- headingWrap.appendChild(element("p", "n1v-subtitle", findings.length ? findings.length + " repeated query pattern" + (findings.length === 1 ? "" : "s") + " on this page" : "This request is clear"));
70
+ headingWrap.appendChild(element(
71
+ "p",
72
+ "n1v-subtitle",
73
+ findings.length ?
74
+ findings.length + " affected source location" + (findings.length === 1 ? "" : "s") +
75
+ " · " + patternCount + " query pattern" + (patternCount === 1 ? "" : "s") :
76
+ "This request is clear"
77
+ ));
48
78
  header.appendChild(headingWrap);
49
79
 
50
80
  var close = element("button", "n1v-close", "×");
@@ -70,6 +100,7 @@
70
100
  container.appendChild(section);
71
101
  return;
72
102
  }
103
+
73
104
  section.appendChild(element("p", "n1v-location", finding.location.path + ":" + finding.location.line));
74
105
  var pre = element("pre", "n1v-code");
75
106
  var code = element("code");
@@ -84,22 +115,60 @@
84
115
  container.appendChild(section);
85
116
  }
86
117
 
118
+ function renderQueries(container, finding) {
119
+ var section = element("section", "n1v-section");
120
+ section.appendChild(element("h3", "n1v-heading", "Repeated query patterns"));
121
+ queryGroupsFor(finding).forEach(function (group, index) {
122
+ var query = element("div", "n1v-query");
123
+ var tables = (group.tables || []).join(", ");
124
+ var metadata = "Pattern " + (index + 1) + " · " + group.query_count +
125
+ " queries · " + group.total_ms + " ms" + (tables ? " · " + tables : "");
126
+ query.appendChild(element("p", "n1v-query-meta", metadata));
127
+ appendCode(query, group.sql);
128
+ section.appendChild(query);
129
+ });
130
+ container.appendChild(section);
131
+ }
132
+
133
+ function renderTreeNode(entry) {
134
+ var item = element("li", "n1v-tree-item");
135
+ if (entry.association) {
136
+ item.appendChild(element(
137
+ "span",
138
+ "n1v-edge",
139
+ entry.association + (entry.macro ? " (" + entry.macro + ")" : "")
140
+ ));
141
+ }
142
+
143
+ var node = element("div", "n1v-node");
144
+ node.appendChild(element("strong", "", entry.name));
145
+ node.appendChild(element("small", "", entry.table));
146
+ item.appendChild(node);
147
+
148
+ if ((entry.children || []).length) {
149
+ var children = element("ul", "n1v-tree");
150
+ entry.children.forEach(function (child) {
151
+ children.appendChild(renderTreeNode(child));
152
+ });
153
+ item.appendChild(children);
154
+ }
155
+ return item;
156
+ }
157
+
87
158
  function renderGraph(container, finding) {
88
159
  var section = element("section", "n1v-section");
89
- section.appendChild(element("h3", "n1v-heading", "Affected models"));
160
+ section.appendChild(element("h3", "n1v-heading", "Affected model tree"));
90
161
  var graph = element("div", "n1v-graph");
91
162
  graph.setAttribute("role", "img");
92
- graph.setAttribute("aria-label", "Model associations involved in the repeated query");
93
- (finding.models || []).forEach(function (model, index) {
94
- if (index > 0) {
95
- var edge = (finding.edges || [])[index - 1];
96
- graph.appendChild(element("span", "n1v-edge", (edge && edge.association ? edge.association + " " : "") + "→"));
97
- }
98
- var node = element("div", "n1v-node");
99
- node.appendChild(element("strong", "", model.name));
100
- node.appendChild(element("small", "", model.table));
101
- graph.appendChild(node);
163
+ graph.setAttribute("aria-label", "Tree of models and associations involved in the repeated queries");
164
+ var tree = element("ul", "n1v-tree");
165
+ var roots = finding.tree || (finding.models || []).map(function (model) {
166
+ return { name: model.name, table: model.table, children: [] };
167
+ });
168
+ roots.forEach(function (entry) {
169
+ tree.appendChild(renderTreeNode(entry));
102
170
  });
171
+ graph.appendChild(tree);
103
172
  section.appendChild(graph);
104
173
  container.appendChild(section);
105
174
  }
@@ -112,6 +181,7 @@
112
181
  var copy = element("button", "n1v-copy", "Copy");
113
182
  copy.type = "button";
114
183
  copy.addEventListener("click", function () {
184
+ if (!navigator.clipboard) return;
115
185
  navigator.clipboard.writeText(suggestion.code).then(function () {
116
186
  copy.textContent = "Copied";
117
187
  window.setTimeout(function () { copy.textContent = "Copy"; }, 1200);
@@ -136,7 +206,12 @@
136
206
  var tabs = element("div", "n1v-tabs");
137
207
  tabs.setAttribute("role", "tablist");
138
208
  findings.forEach(function (finding, tabIndex) {
139
- var tab = element("button", "n1v-tab", "#" + (tabIndex + 1) + " · " + finding.query_count + " queries");
209
+ var groupCount = queryGroupsFor(finding).length;
210
+ var tab = element(
211
+ "button",
212
+ "n1v-tab",
213
+ "#" + (tabIndex + 1) + " · " + groupCount + " pattern" + (groupCount === 1 ? "" : "s")
214
+ );
140
215
  tab.type = "button";
141
216
  tab.setAttribute("role", "tab");
142
217
  tab.setAttribute("aria-selected", String(tabIndex === index));
@@ -149,6 +224,7 @@
149
224
  var finding = findings[index];
150
225
  renderSource(body, finding);
151
226
  renderGraph(body, finding);
227
+ renderQueries(body, finding);
152
228
  renderFixes(body, finding);
153
229
  }
154
230
 
@@ -167,7 +243,7 @@
167
243
  if (event.key === "Escape" && panel.getAttribute("aria-hidden") === "false") setOpen(false);
168
244
  });
169
245
 
170
- renderFinding(0);
246
+ renderFinding(initialFindingIndex);
171
247
  root.appendChild(panel);
172
248
  root.appendChild(launcher);
173
249
  }());
File without changes
@@ -8,5 +8,10 @@ module NPlusInsight
8
8
  @detection = Store.find(params[:id])
9
9
  return head :not_found unless @detection
10
10
  end
11
+
12
+ def clear
13
+ Store.clear
14
+ redirect_to root_path, status: :see_other, notice: "Stored detections cleared."
15
+ end
11
16
  end
12
17
  end