rails_vitals 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +340 -0
  4. data/Rakefile +6 -0
  5. data/app/assets/stylesheets/rails_vitals/application.css +180 -0
  6. data/app/controllers/rails_vitals/application_controller.rb +30 -0
  7. data/app/controllers/rails_vitals/associations_controller.rb +8 -0
  8. data/app/controllers/rails_vitals/dashboard_controller.rb +59 -0
  9. data/app/controllers/rails_vitals/heatmap_controller.rb +39 -0
  10. data/app/controllers/rails_vitals/models_controller.rb +65 -0
  11. data/app/controllers/rails_vitals/n_plus_ones_controller.rb +43 -0
  12. data/app/controllers/rails_vitals/requests_controller.rb +44 -0
  13. data/app/helpers/rails_vitals/application_helper.rb +63 -0
  14. data/app/jobs/rails_vitals/application_job.rb +4 -0
  15. data/app/mailers/rails_vitals/application_mailer.rb +6 -0
  16. data/app/models/rails_vitals/application_record.rb +5 -0
  17. data/app/views/layouts/rails_vitals/application.html.erb +27 -0
  18. data/app/views/rails_vitals/associations/index.html.erb +370 -0
  19. data/app/views/rails_vitals/dashboard/index.html.erb +158 -0
  20. data/app/views/rails_vitals/heatmap/index.html.erb +66 -0
  21. data/app/views/rails_vitals/models/index.html.erb +117 -0
  22. data/app/views/rails_vitals/n_plus_ones/index.html.erb +49 -0
  23. data/app/views/rails_vitals/n_plus_ones/show.html.erb +139 -0
  24. data/app/views/rails_vitals/requests/index.html.erb +60 -0
  25. data/app/views/rails_vitals/requests/show.html.erb +396 -0
  26. data/config/routes.rb +9 -0
  27. data/lib/rails_vitals/analyzers/association_mapper.rb +121 -0
  28. data/lib/rails_vitals/analyzers/n_plus_one_aggregator.rb +116 -0
  29. data/lib/rails_vitals/analyzers/sql_tokenizer.rb +240 -0
  30. data/lib/rails_vitals/collector.rb +78 -0
  31. data/lib/rails_vitals/configuration.rb +27 -0
  32. data/lib/rails_vitals/engine.rb +25 -0
  33. data/lib/rails_vitals/instrumentation/callback_instrumentation.rb +30 -0
  34. data/lib/rails_vitals/middleware/panel_injector.rb +75 -0
  35. data/lib/rails_vitals/notifications/subscriber.rb +59 -0
  36. data/lib/rails_vitals/panel_renderer.rb +233 -0
  37. data/lib/rails_vitals/request_record.rb +51 -0
  38. data/lib/rails_vitals/scorers/base_scorer.rb +25 -0
  39. data/lib/rails_vitals/scorers/composite_scorer.rb +36 -0
  40. data/lib/rails_vitals/scorers/n_plus_one_scorer.rb +43 -0
  41. data/lib/rails_vitals/scorers/query_scorer.rb +42 -0
  42. data/lib/rails_vitals/store.rb +34 -0
  43. data/lib/rails_vitals/version.rb +3 -0
  44. data/lib/rails_vitals.rb +33 -0
  45. data/lib/tasks/rails_vitals_tasks.rake +4 -0
  46. metadata +113 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: f17a9a533950167bfbd68780d388d56e95ec38fb442958ddf7228d323190eb24
4
+ data.tar.gz: 165535e4b833e3f622965142d6605a7423a85a0a607131eed1a3240c4cea8fe7
5
+ SHA512:
6
+ metadata.gz: 0d7b5af657229031b4189605e661028ccda2f169f585acc48188d7a34a22aa0ed7422bee6291b79edf52d7cee46aedc403c0a823fa8c1f6139a4925b8a4dd4dc
7
+ data.tar.gz: 49d029d3b489e21656b1f750066d759611785b077f9e32c1a42a2d2d784c2abe3efa31728100250d17c651f765072b11cf9c151bdb9998f06d6677bc5752dd05
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright David Sanchez
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,340 @@
1
+ # ⚑ RailsVitals
2
+
3
+ > **The Rails gem that made me understand performance.**
4
+
5
+ RailsVitals is a zero-dependency Rails Engine that instruments every request in your app and surfaces performance diagnostics: N+1 queries, slow SQL, fat callbacks, and health scores through an embedded admin UI and an injectable panel overlay.
6
+
7
+ It doesn't just tell you something is wrong. It shows you **why**, **where**, and **what to do about it**.
8
+
9
+ ---
10
+
11
+ ## Screenshots
12
+
13
+ ![RailsVitals Dashboard](https://github.com/user-attachments/assets/caa21065-5929-4c3a-9dbe-34fb01799817)
14
+
15
+ > Full admin UI at `/rails_vitals` with Dashboard, Request History,
16
+ > Request Detail with Query DNA, Endpoint Heatmap, Per-Model Breakdown,
17
+ > N+1 Patterns and Association Map.
18
+
19
+ ---
20
+
21
+ ## Features
22
+
23
+ ### πŸ”΄ Per-Request Health Score
24
+ Every request gets a score from 0–100 based on query volume and N+1 severity. Scores are color-coded: Healthy (90–100), Acceptable (70–89), Warning (50–69), Critical (0–49).
25
+
26
+ ![Health Score](https://github.com/user-attachments/assets/3e10b4ea-556e-4d51-b32b-f8bb6f9a267b)
27
+
28
+ ### 🧬 Query DNA β€” Visual SQL Fingerprinting
29
+ Every query in a request is decomposed into color-coded tokens: `SELECT *`, `WHERE fk =`, `IN (...)`, `JOIN`, `ORDER BY`, `OFFSET`, and more. Click any query to expand its DNA. Click any token to read an education card explaining what it means, why it matters, and how to fix it.
30
+
31
+ ![Query DNA](https://github.com/user-attachments/assets/08857413-4759-460c-ada4-1813ca462baf)
32
+
33
+ ### πŸ—ΊοΈ Association Map
34
+ A live SVG diagram of your ActiveRecord model graph, annotated with N+1 status, query counts, average query time, foreign key names, and index status on every edge. Nodes light up red when N+1 patterns are detected. Dashed edges signal missing indexes on foreign keys. Click any node to open a detail panel with fix suggestions and links to affected requests.
35
+
36
+ ![Association Map](https://github.com/user-attachments/assets/7a984138-f436-44f6-b586-b54e02208a7c)
37
+
38
+ ### πŸ”₯ Endpoint Heatmap
39
+ A ranked table of every endpoint in your app sorted by worst average health score. Columns: average score, hit count, average query count, average DB time, average callback time, N+1 frequency. The worst offenders surface immediately.
40
+
41
+ ![Endpoint Heatmap](https://github.com/user-attachments/assets/7503f788-e5f7-4ec4-92b2-4f6ed3ae3b2d)
42
+
43
+ ### πŸ“Š Per-Model Breakdown
44
+ Which ActiveRecord models are hammering your database? The model breakdown aggregates queries by table, shows total query count, total DB time, average query time, and the endpoints responsible.
45
+
46
+ ![Model Breakdown](https://github.com/user-attachments/assets/deb0e2f8-cc7e-479e-8b0a-43cf5a15a629)
47
+
48
+ ### πŸ” N+1 Pattern Detector
49
+ Cross-request N+1 aggregation using normalized SQL fingerprinting. Each pattern shows total occurrences, affected endpoints, and a concrete fix suggestion generated by reflecting on your actual ActiveRecord associations e.g. `Post.includes(:likes)`, not a generic hint.
50
+
51
+ ![N+1 Pattern Detector](https://github.com/user-attachments/assets/150c3b55-dcb2-48e1-87a7-1471cff3e19c)
52
+
53
+
54
+ ### πŸ’₯ Impact Simulator
55
+ Each N+1 pattern has a detail page showing affected requests, estimated query savings, and a generated migration-ready fix. See the blast radius before you write a line of code.
56
+
57
+ ![Impact Simulator](https://github.com/user-attachments/assets/c6eaf195-7652-4319-bd15-476033e64896)
58
+
59
+ ### 🎭 Callback Map
60
+ Every ActiveRecord callback (`before_save`, `after_create`, `before_validation`, etc.) is timed and grouped by model in the Request Detail view. Expensive callbacks surface immediately β€” including hidden side effects like callbacks that trigger additional queries.
61
+
62
+ ![Callback Map](https://github.com/user-attachments/assets/8a33c957-191a-4e23-83bf-99ec25b462a8)
63
+
64
+ ### πŸ›‘οΈ Injected Panel
65
+ A collapsible overlay injected into every HTML response showing the current request's score, query count, DB time, N+1 count, and callback time. Zero configuration. Disappears in production.
66
+
67
+ ![Injected Panel](https://github.com/user-attachments/assets/c95e590b-02e4-4b25-90bb-d3f013dae6ff)
68
+
69
+ ---
70
+
71
+ ## Installation
72
+
73
+ Add to your Gemfile:
74
+
75
+ ```ruby
76
+ gem "rails_vitals", group: :development
77
+ ```
78
+
79
+ Run:
80
+
81
+ ```bash
82
+ bundle install
83
+ ```
84
+
85
+ Mount the engine in `config/routes.rb`:
86
+
87
+ ```ruby
88
+ mount RailsVitals::Engine, at: "/rails_vitals"
89
+ ```
90
+
91
+ That's it. Visit `/rails_vitals` and start browsing your app.
92
+
93
+ ---
94
+
95
+ ## Configuration
96
+
97
+ RailsVitals works out of the box with sensible defaults. To customize, add an initializer:
98
+
99
+ ```ruby
100
+ # config/initializers/rails_vitals.rb
101
+ RailsVitals.configure do |config|
102
+ # Enable/disable instrumentation entirely
103
+ config.enabled = !Rails.env.production?
104
+
105
+ # Ring buffer size β€” how many requests to keep in memory
106
+ config.store_size = 200
107
+
108
+ # Query count thresholds for scoring
109
+ config.query_warn_threshold = 10 # above this β†’ score starts dropping
110
+ config.query_critical_threshold = 25 # above this β†’ score = 0
111
+
112
+ # DB time thresholds (ms) for slow query detection
113
+ config.db_time_warn_ms = 100
114
+ config.db_time_critical_ms = 500
115
+
116
+ # Admin UI authentication
117
+ # :none β†’ no auth (default for development)
118
+ # :basic β†’ HTTP Basic Auth
119
+ # lambda β†’ custom auth logic
120
+ config.auth = :none
121
+
122
+ # Basic auth credentials (if auth: :basic)
123
+ config.basic_auth_username = "admin"
124
+ config.basic_auth_password = "secret"
125
+
126
+ # Custom auth lambda (if auth: :lambda)
127
+ # config.auth = ->(request) { request.session[:admin] == true }
128
+ end
129
+ ```
130
+
131
+ ---
132
+
133
+ ## Admin UI
134
+
135
+ Navigate to `/rails_vitals` to access the full admin interface.
136
+
137
+ | Page | Path | Description |
138
+ |------|------|-------------|
139
+ | Dashboard | `/rails_vitals` | Score distribution, health trend, query volume |
140
+ | Requests | `/rails_vitals/requests` | Full request history with filters |
141
+ | Request Detail | `/rails_vitals/requests/:id` | Queries, Query DNA, Callback Map, Score Projection |
142
+ | Heatmap | `/rails_vitals/heatmap` | Endpoints ranked by worst health score |
143
+ | Models | `/rails_vitals/models` | Per-model query breakdown |
144
+ | N+1 Patterns | `/rails_vitals/n_plus_ones` | Cross-request N+1 aggregation with fix suggestions |
145
+ | Association Map | `/rails_vitals/associations` | Live SVG model graph with N+1 and index annotations |
146
+
147
+ ---
148
+
149
+ ## How Scoring Works
150
+
151
+ RailsVitals scores each request using a `CompositeScorer` with two weighted dimensions:
152
+
153
+ ```
154
+ Score = (QueryScore Γ— 40%) + (N+1Score Γ— 60%)
155
+ ```
156
+
157
+ **QueryScore** (40%) β€” penalizes query volume:
158
+ - ≀ `query_warn_threshold` queries β†’ 100
159
+ - β‰₯ `query_critical_threshold` queries β†’ 0
160
+ - Between thresholds β†’ linear interpolation
161
+
162
+ **N+1Score** (60%) β€” penalizes detected patterns:
163
+ - 0 patterns β†’ 100
164
+ - 1 pattern β†’ 75
165
+ - 2 patterns β†’ 50
166
+ - 3 patterns β†’ 25
167
+ - 4+ patterns β†’ 0
168
+
169
+ N+1 patterns are weighted more heavily because they represent an architectural problem that grows worse as data scales not just a snapshot of current query volume.
170
+
171
+ ---
172
+
173
+ ## Query DNA Token Reference
174
+
175
+ | Token | Color | Risk | What it means |
176
+ |-------|-------|------|---------------|
177
+ | `SELECT *` | Blue | ⚠ Warning | Fetches all columns β€” consider `.select(:id, :name)` |
178
+ | `SELECT` | Blue | βœ… Healthy | Specific column selection |
179
+ | `COUNT(*)` | Amber | ⚠ Warning | Aggregation in a loop = N+1 variant |
180
+ | `AGGREGATE` | Amber | ⚠ Warning | SUM/AVG/MIN/MAX in a loop |
181
+ | `FROM` | Green | βœ… Healthy | Identifies the model being queried |
182
+ | `WHERE fk =` | Red | πŸ”΄ Danger | Single FK lookup β€” the N+1 signature |
183
+ | `WHERE` | Orange | β€” Neutral | Filter condition β€” check for missing index |
184
+ | `IN (...)` | Green | βœ… Healthy | Batch lookup β€” eager loading is working |
185
+ | `INNER JOIN` | Purple | β€” Neutral | `.joins()` β€” note: association not loaded |
186
+ | `LEFT JOIN` | Purple | β€” Neutral | `.eager_load()` or `.left_joins()` |
187
+ | `ORDER BY` | Cyan | ⚠ Warning | Ensure sort column has an index |
188
+ | `LIMIT` | Gray | βœ… Healthy | Good β€” always paginate |
189
+ | `OFFSET` | Red | ⚠ Warning | O(n) at scale β€” consider cursor pagination |
190
+ | `GROUP BY` | Cyan | β€” Neutral | Consider counter cache for frequent use |
191
+
192
+ ---
193
+
194
+ ## N+1 Fix Suggestions
195
+
196
+ RailsVitals generates fix suggestions by reflecting on your actual ActiveRecord associations, not by guessing. Given a detected pattern:
197
+
198
+ ```sql
199
+ SELECT "likes".* FROM "likes" WHERE "likes"."post_id" = ?
200
+ ```
201
+
202
+ It extracts the foreign key (`post_id`), infers the owner model (`Post`), reflects on `Post.reflect_on_all_associations`, and generates:
203
+
204
+ ```ruby
205
+ Post.includes(:likes)
206
+ ```
207
+
208
+ Real associations, real fix, zero guesswork.
209
+
210
+ ---
211
+
212
+ ## Association Map β€” Reading the Diagram
213
+
214
+ - 🟒 **Green node** β€” model is queried, no N+1 detected
215
+ - πŸ”΄ **Red node** β€” N+1 patterns detected on this model's associations
216
+ - ⬜ **Gray node** β€” model exists but hasn't been queried in recent requests
217
+ - **Solid edge** β€” foreign key is indexed βœ…
218
+ - **Dashed edge** β€” foreign key is missing an index ⚠️
219
+ - **Orange edge** β€” `has_many` / `has_one`
220
+ - **Purple edge** β€” `belongs_to`
221
+ - **Red edge** β€” association has active N+1 patterns
222
+
223
+ Click any node to open the detail panel with query stats, association breakdown, fix suggestions, and links to affected requests.
224
+
225
+ ---
226
+
227
+ ## Architecture
228
+
229
+ RailsVitals is a mountable Rails Engine with zero runtime dependencies beyond Rails itself.
230
+
231
+ ```
232
+ rails_vitals/
233
+ β”œβ”€β”€ lib/
234
+ β”‚ └── rails_vitals/
235
+ β”‚ β”œβ”€β”€ engine.rb # Mountable engine
236
+ β”‚ β”œβ”€β”€ configuration.rb # Config object
237
+ β”‚ β”œβ”€β”€ collector.rb # Thread-local request collector
238
+ β”‚ β”œβ”€β”€ store.rb # Thread-safe in-memory ring buffer
239
+ β”‚ β”œβ”€β”€ request_record.rb # Immutable request snapshot
240
+ β”‚ β”œβ”€β”€ panel_renderer.rb # Injected HTML panel
241
+ β”‚ β”œβ”€β”€ sse_writer.rb # Server-Sent Events writer
242
+ β”‚ β”œβ”€β”€ notifications/
243
+ β”‚ β”‚ └── subscriber.rb # AS::Notifications SQL + controller hooks
244
+ β”‚ β”œβ”€β”€ instrumentation/
245
+ β”‚ β”‚ └── callback_instrumentation.rb # Module prepend for AR callbacks
246
+ β”‚ β”œβ”€β”€ analyzers/
247
+ β”‚ β”‚ β”œβ”€β”€ sql_tokenizer.rb # Query DNA tokenizer
248
+ β”‚ β”‚ β”œβ”€β”€ n_plus_one_aggregator.rb # Cross-request N+1 aggregation
249
+ β”‚ β”‚ └── association_mapper.rb # AR reflection + SVG layout
250
+ β”‚ β”œβ”€β”€ scorers/
251
+ β”‚ β”‚ β”œβ”€β”€ base_scorer.rb
252
+ β”‚ β”‚ β”œβ”€β”€ query_scorer.rb # 40% weight
253
+ β”‚ β”‚ β”œβ”€β”€ n_plus_one_scorer.rb # 60% weight
254
+ β”‚ β”‚ └── composite_scorer.rb
255
+ β”‚ └── middleware/
256
+ β”‚ └── panel_injector.rb # Rack middleware for panel injection
257
+ └── app/
258
+ β”œβ”€β”€ controllers/rails_vitals/
259
+ β”‚ β”œβ”€β”€ dashboard_controller.rb
260
+ β”‚ β”œβ”€β”€ requests_controller.rb
261
+ β”‚ β”œβ”€β”€ heatmap_controller.rb
262
+ β”‚ β”œβ”€β”€ models_controller.rb
263
+ β”‚ β”œβ”€β”€ n_plus_ones_controller.rb
264
+ β”‚ β”œβ”€β”€ associations_controller.rb
265
+ β”‚ └── live_controller.rb
266
+ └── views/rails_vitals/
267
+ β”œβ”€β”€ dashboard/
268
+ β”œβ”€β”€ requests/
269
+ β”œβ”€β”€ heatmap/
270
+ β”œβ”€β”€ models/
271
+ β”œβ”€β”€ n_plus_ones/
272
+ β”œβ”€β”€ associations/
273
+ └── live/
274
+ ```
275
+
276
+ **Key architectural decisions:**
277
+
278
+ - **Zero JS dependencies** β€” no Chartkick, no D3, no Chart.js. Tables for data, SVG for diagrams, vanilla JS for interactions.
279
+ - **Thread-local Collector** β€” instrumentation state is stored per-thread, never shared between concurrent requests.
280
+ - **In-memory ring buffer** β€” the Store keeps the last N requests in memory. No database writes, no schema migrations.
281
+ - **Module prepend for callbacks** β€” callback instrumentation wraps `ActiveRecord::Base#run_callbacks` via `Module#prepend`. No TracePoint, no monkey-patching.
282
+ - **ActiveSupport::Notifications** β€” SQL and controller events are captured via the standard Rails instrumentation bus.
283
+
284
+ ---
285
+
286
+ ## Philosophy
287
+
288
+ Most Rails performance tools tell you what is slow. RailsVitals tells you **why it is slow** and **what the right mental model is**.
289
+
290
+ Every feature is designed around a teaching moment:
291
+
292
+ - Query DNA turns SQL into a readable fingerprint with explanations for each token
293
+ - The Association Map connects your data model structure to live performance data
294
+ - N+1 fix suggestions come from your actual associations, not generic advice
295
+ - Score Projection lets you understand the impact of a fix before writing code
296
+ - The Impact Simulator shows blast radius, how many requests are affected, how many queries would be saved
297
+
298
+ The goal is not just a faster app. The goal is a developer who understands **why** the app was slow and **how** to prevent it next time.
299
+
300
+ ---
301
+
302
+ ## Requirements
303
+
304
+ - Ruby 3.0+
305
+ - Rails 7.0+
306
+ - PostgreSQL (some internal query filters are PostgreSQL-specific)
307
+
308
+ ---
309
+
310
+ ## Development
311
+
312
+ ```bash
313
+ git clone https://github.com/your-username/rails_vitals
314
+ cd rails_vitals
315
+ bundle install
316
+ ```
317
+
318
+ To test against a real app, add to your Gemfile with a local path:
319
+
320
+ ```ruby
321
+ gem "rails_vitals", path: "../rails_vitals"
322
+ ```
323
+
324
+ ---
325
+
326
+ ## Contributing
327
+
328
+ Bug reports and pull requests are welcome on GitHub. This project is intended to be a safe, welcoming space for collaboration.
329
+
330
+ ---
331
+
332
+ ## License
333
+
334
+ The gem is available as open source under the terms of the [MIT License](MIT-LICENSE).
335
+
336
+ ---
337
+
338
+ ## Author
339
+
340
+ Built by [David](https://codeando.dev/)
data/Rakefile ADDED
@@ -0,0 +1,6 @@
1
+ require "bundler/setup"
2
+
3
+ APP_RAKEFILE = File.expand_path("test/dummy/Rakefile", __dir__)
4
+ load "rails/tasks/engine.rake"
5
+
6
+ require "bundler/gem_tasks"
@@ -0,0 +1,180 @@
1
+ /*
2
+ * This is a manifest file that'll be compiled into application.css, which will include all the files
3
+ * listed below.
4
+ *
5
+ * Any CSS and SCSS file within this directory, lib/assets/stylesheets, vendor/assets/stylesheets,
6
+ * or any plugin's vendor/assets/stylesheets directory can be referenced here using a relative path.
7
+ *
8
+ * You're free to add application-wide styles to this file and they'll appear at the bottom of the
9
+ * compiled file so the styles you add here take precedence over styles defined in any other CSS/SCSS
10
+ * files in this directory. Styles in this file should be added after the last require_* statement.
11
+ * It is generally better to create a new file per style scope.
12
+ *
13
+ *= require_tree .
14
+ *= require_self
15
+ */
16
+
17
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
18
+
19
+ body {
20
+ background: #0f0f1a;
21
+ color: #e2e8f0;
22
+ font-family: ui-monospace, monospace;
23
+ font-size: 13px;
24
+ min-height: 100vh;
25
+ }
26
+
27
+ a { color: #90cdf4; text-decoration: none; }
28
+ a:hover { text-decoration: underline; }
29
+
30
+ .nav {
31
+ background: #1a1a2e;
32
+ border-bottom: 1px solid #2d3748;
33
+ padding: 16px 24px;
34
+ display: flex;
35
+ align-items: center;
36
+ gap: 24px;
37
+ }
38
+
39
+ .nav-brand {
40
+ font-size: 20px;
41
+ font-weight: bold;
42
+ color: #90cdf4;
43
+ margin-right: 16px;
44
+ }
45
+
46
+ .nav a.nav-link {
47
+ color: #a0aec0;
48
+ font-size: 12px;
49
+ }
50
+
51
+ .nav a:hover { color: #e2e8f0; }
52
+ .nav a.active { color: #90cdf4; }
53
+
54
+ .container { padding: 24px; max-width: 1200px; margin: 0 auto; }
55
+
56
+ .page-title {
57
+ font-size: 18px;
58
+ font-weight: bold;
59
+ color: #e2e8f0;
60
+ margin-bottom: 20px;
61
+ }
62
+
63
+ .card {
64
+ background: #1a1a2e;
65
+ border: 1px solid #2d3748;
66
+ border-radius: 8px;
67
+ padding: 16px;
68
+ margin-bottom: 16px;
69
+ }
70
+
71
+ .card-title {
72
+ font-size: 11px;
73
+ text-transform: uppercase;
74
+ letter-spacing: 0.08em;
75
+ color: #a0aec0;
76
+ margin-bottom: 12px;
77
+
78
+ .card-title-description {
79
+ font-size: 10px;
80
+ color: #718096;
81
+ text-transform: none;
82
+ letter-spacing: normal;
83
+ margin-left: 8px;
84
+ font-weight: normal;
85
+ }
86
+ }
87
+
88
+ .badge {
89
+ display: inline-block;
90
+ padding: 2px 8px;
91
+ border-radius: 4px;
92
+ font-size: 11px;
93
+ font-weight: bold;
94
+ }
95
+
96
+ .badge-healthy { background: #276749; color: #fff; }
97
+ .badge-acceptable { background: #2b6cb0; color: #fff; }
98
+ .badge-warning { background: #b7791f; color: #fff; }
99
+ .badge-critical { background: #c53030; color: #fff; }
100
+
101
+ table { width: 100%; border-collapse: collapse; }
102
+ th {
103
+ text-align: left;
104
+ color: #a0aec0;
105
+ font-size: 11px;
106
+ text-transform: uppercase;
107
+ letter-spacing: 0.06em;
108
+ padding: 8px 12px;
109
+ border-bottom: 1px solid #2d3748;
110
+ }
111
+ td {
112
+ padding: 10px 12px;
113
+ border-bottom: 1px solid #1e2535;
114
+ color: #e2e8f0;
115
+ }
116
+ tr:hover td { background: #1e2535; }
117
+
118
+ .grid-3 {
119
+ display: grid;
120
+ grid-template-columns: repeat(3, 1fr);
121
+ gap: 16px;
122
+ margin-bottom: 16px;
123
+ }
124
+
125
+ .stat-card {
126
+ background: #1a1a2e;
127
+ border: 1px solid #2d3748;
128
+ border-radius: 8px;
129
+ padding: 16px;
130
+ text-align: center;
131
+ }
132
+
133
+ .stat-value {
134
+ font-size: 28px;
135
+ font-weight: bold;
136
+ color: #90cdf4;
137
+ margin-bottom: 4px;
138
+ }
139
+
140
+ .stat-label {
141
+ font-size: 11px;
142
+ color: #a0aec0;
143
+ text-transform: uppercase;
144
+ letter-spacing: 0.06em;
145
+ }
146
+
147
+ .filter-bar {
148
+ display: flex;
149
+ gap: 8px;
150
+ margin-bottom: 16px;
151
+ flex-wrap: wrap;
152
+ }
153
+
154
+ .filter-bar a {
155
+ padding: 4px 12px;
156
+ border: 1px solid #2d3748;
157
+ border-radius: 4px;
158
+ font-size: 11px;
159
+ color: #a0aec0;
160
+ }
161
+
162
+ .filter-bar a:hover,
163
+ .filter-bar a.active { border-color: #90cdf4; color: #90cdf4; }
164
+
165
+ .sql {
166
+ color: #90cdf4;
167
+ font-size: 11px;
168
+ white-space: nowrap;
169
+ overflow: hidden;
170
+ text-overflow: ellipsis;
171
+ max-width: 400px;
172
+ }
173
+
174
+ .n1-badge {
175
+ background: #c53030;
176
+ color: #fff;
177
+ padding: 1px 6px;
178
+ border-radius: 3px;
179
+ font-size: 10px;
180
+ }
@@ -0,0 +1,30 @@
1
+ module RailsVitals
2
+ class ApplicationController < ActionController::Base
3
+ before_action :authenticate!
4
+ before_action :flag_own_request
5
+
6
+ private
7
+
8
+ def authenticate!
9
+ auth = RailsVitals.config.auth
10
+
11
+ case auth
12
+ when :none
13
+ true
14
+ when :basic
15
+ authenticate_or_request_with_http_basic("RailsVitals") do |username, password|
16
+ username == RailsVitals.config.basic_auth_username &&
17
+ password == RailsVitals.config.basic_auth_password
18
+ end
19
+ when Proc
20
+ unless auth.call(self)
21
+ render plain: "Unauthorized", status: :unauthorized
22
+ end
23
+ end
24
+ end
25
+
26
+ def flag_own_request
27
+ Thread.current[:rails_vitals_own_request] = true
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,8 @@
1
+ module RailsVitals
2
+ class AssociationsController < ApplicationController
3
+ def index
4
+ @nodes, @canvas_height = Analyzers::AssociationMapper.build(RailsVitals.store)
5
+ @node_map = @nodes.index_by(&:name)
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,59 @@
1
+ module RailsVitals
2
+ class DashboardController < ApplicationController
3
+ def index
4
+ @records = RailsVitals.store.all.reverse
5
+ @total = @records.size
6
+ @avg_score = average(@records, :score)
7
+ @avg_queries = average(@records, :total_query_count)
8
+ @avg_db_time = average(@records, :total_db_time_ms)
9
+ @top_offenders = top_offenders(@records)
10
+ @health_trend = health_trend_data(@records)
11
+ @score_distribution = score_distribution_data(@records)
12
+ @query_volume = query_volume_data(@records)
13
+ end
14
+
15
+ private
16
+
17
+ def average(records, method)
18
+ return 0 if records.empty?
19
+
20
+ (records.sum(&method).to_f / records.size).round(1)
21
+ end
22
+
23
+ def top_offenders(records)
24
+ records
25
+ .group_by(&:endpoint)
26
+ .transform_values do |reqs|
27
+ {
28
+ count: reqs.size,
29
+ avg_score: average(reqs, :score),
30
+ avg_queries: average(reqs, :total_query_count),
31
+ avg_db_time_ms: average(reqs, :total_db_time_ms)
32
+ }
33
+ end
34
+ .sort_by { |_, v| v[:avg_score] }
35
+ .first(5)
36
+ end
37
+
38
+ def health_trend_data(records)
39
+ records.first(10).map do |r|
40
+ [ r.endpoint, r.score ]
41
+ end
42
+ end
43
+
44
+ def score_distribution_data(records)
45
+ {
46
+ "Healthy (90-100)" => records.count { |r| r.score >= 90 },
47
+ "Acceptable (70-89)" => records.count { |r| (70..89).include?(r.score) },
48
+ "Warning (50-69)" => records.count { |r| (50..69).include?(r.score) },
49
+ "Critical (0-49)" => records.count { |r| r.score < 50 }
50
+ }
51
+ end
52
+
53
+ def query_volume_data(records)
54
+ records.first(10).each_with_index.map do |r, i|
55
+ [ "##{i + 1} #{r.endpoint}", { queries: r.total_query_count, db_time: r.total_db_time_ms.round(1) } ]
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,39 @@
1
+ module RailsVitals
2
+ class HeatmapController < ApplicationController
3
+ def index
4
+ records = RailsVitals.store.all
5
+ @heatmap = build_heatmap(records)
6
+ @total = records.size
7
+ end
8
+
9
+ private
10
+
11
+ def build_heatmap(records)
12
+ records
13
+ .group_by(&:endpoint)
14
+ .map do |endpoint, reqs|
15
+ {
16
+ endpoint: endpoint,
17
+ hits: reqs.size,
18
+ avg_score: average(reqs, :score),
19
+ avg_queries: average(reqs, :total_query_count),
20
+ avg_db_time: average(reqs, :total_db_time_ms),
21
+ avg_callback_time: average(reqs, :total_callback_time_ms),
22
+ n_plus_one_freq: n_plus_one_frequency(reqs)
23
+ }
24
+ end
25
+ .sort_by { |row| row[:avg_score] }
26
+ end
27
+
28
+ def average(records, method)
29
+ return 0.0 if records.empty?
30
+ (records.sum { |r| r.public_send(method) }.to_f / records.size).round(1)
31
+ end
32
+
33
+ def n_plus_one_frequency(reqs)
34
+ reqs_with_n1 = reqs.count { |r| r.n_plus_one_patterns.any? }
35
+ return 0.0 if reqs.empty?
36
+ ((reqs_with_n1.to_f / reqs.size) * 100).round(1)
37
+ end
38
+ end
39
+ end