rails_pulse 0.4.0.pre.2 → 0.4.0.pre.6

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 (76) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +115 -171
  3. data/README.md +83 -109
  4. data/app/controllers/concerns/session_filters_concern.rb +4 -3
  5. data/app/controllers/concerns/time_range_concern.rb +29 -19
  6. data/app/controllers/rails_pulse/application_controller.rb +67 -4
  7. data/app/controllers/rails_pulse/dashboard_controller.rb +20 -11
  8. data/app/controllers/rails_pulse/deployments_controller.rb +1 -4
  9. data/app/controllers/rails_pulse/routes_controller.rb +46 -0
  10. data/app/helpers/rails_pulse/application_helper.rb +4 -0
  11. data/app/helpers/rails_pulse/breadcrumbs_helper.rb +6 -3
  12. data/app/javascript/rails_pulse/controllers/chart_controller.js +27 -0
  13. data/app/models/rails_pulse/cards/base.rb +61 -25
  14. data/app/models/rails_pulse/dashboard/charts/response_time_percentiles.rb +38 -15
  15. data/app/models/rails_pulse/dashboard/charts/throughput_and_errors.rb +36 -13
  16. data/app/models/rails_pulse/dashboard/concerns/time_range_helper.rb +3 -0
  17. data/app/models/rails_pulse/dashboard/health_summary.rb +3 -1
  18. data/app/models/rails_pulse/dashboard/needs_attention.rb +3 -1
  19. data/app/models/rails_pulse/dashboard/storage_status.rb +38 -68
  20. data/app/models/rails_pulse/deployment.rb +6 -0
  21. data/app/models/rails_pulse/exceptions/cards/exception_rate.rb +1 -5
  22. data/app/models/rails_pulse/exceptions/cards/open_groups.rb +4 -4
  23. data/app/models/rails_pulse/exceptions/cards/total_occurrences.rb +3 -4
  24. data/app/models/rails_pulse/jobs/cards/failure_rate.rb +7 -20
  25. data/app/models/rails_pulse/jobs/cards/p95_duration.rb +5 -8
  26. data/app/models/rails_pulse/jobs/cards/total_runs.rb +4 -4
  27. data/app/models/rails_pulse/queries/cards/average_query_times.rb +6 -9
  28. data/app/models/rails_pulse/queries/cards/database_load.rb +7 -10
  29. data/app/models/rails_pulse/queries/cards/execution_rate.rb +4 -6
  30. data/app/models/rails_pulse/queries/cards/percentile_query_times.rb +5 -8
  31. data/app/models/rails_pulse/route.rb +7 -1
  32. data/app/models/rails_pulse/routes/cards/error_rates.rb +5 -11
  33. data/app/models/rails_pulse/routes/cards/percentile_response_times.rb +3 -1
  34. data/app/models/rails_pulse/routes/cards/request_count_totals.rb +4 -6
  35. data/app/models/rails_pulse/summary.rb +13 -18
  36. data/app/models/rails_pulse/time_range_preference.rb +28 -0
  37. data/app/models/rails_pulse/time_window.rb +61 -0
  38. data/app/services/rails_pulse/exception_capture_service.rb +2 -0
  39. data/app/services/rails_pulse/route_controller_action_backfiller.rb +0 -4
  40. data/app/services/rails_pulse/summary_service.rb +112 -162
  41. data/app/services/rails_pulse/tag_filter_service.rb +10 -1
  42. data/app/views/layouts/rails_pulse/_time_range_selector.html.erb +2 -2
  43. data/app/views/rails_pulse/dashboard/index.html.erb +6 -3
  44. data/app/views/rails_pulse/routes/_archived_summary_table.html.erb +46 -0
  45. data/app/views/rails_pulse/routes/_archived_table_pagination.html.erb +28 -0
  46. data/app/views/rails_pulse/routes/show.html.erb +14 -2
  47. data/app/views/rails_pulse/shared/schema_outdated.html.erb +96 -0
  48. data/exe/rails_pulse_server +9 -0
  49. data/lib/generators/rails_pulse/install_generator.rb +7 -2
  50. data/lib/generators/rails_pulse/templates/rails_pulse.rb +28 -2
  51. data/lib/generators/rails_pulse/upgrade_generator.rb +56 -5
  52. data/lib/rails_pulse/cleanup_service.rb +7 -7
  53. data/lib/rails_pulse/configuration.rb +30 -1
  54. data/lib/rails_pulse/engine.rb +19 -44
  55. data/lib/rails_pulse/installers/config_updater.rb +17 -0
  56. data/lib/rails_pulse/job_run_collector.rb +1 -1
  57. data/lib/rails_pulse/middleware/asset_server.rb +5 -4
  58. data/lib/rails_pulse/middleware/request_collector.rb +2 -17
  59. data/lib/rails_pulse/schema_check.rb +136 -0
  60. data/lib/rails_pulse/standalone.rb +62 -0
  61. data/lib/rails_pulse/subscribers/operation_subscriber.rb +45 -3
  62. data/lib/rails_pulse/tasks/status_reporter.rb +217 -0
  63. data/lib/rails_pulse/tracker.rb +242 -25
  64. data/lib/rails_pulse/version.rb +1 -1
  65. data/lib/rails_pulse.rb +5 -16
  66. data/lib/rails_pulse_server.ru +58 -11
  67. data/lib/tasks/rails_pulse.rake +48 -2
  68. data/lib/tasks/rails_pulse_tasks.rake +5 -0
  69. data/public/rails-pulse-assets/rails-pulse.js +1 -1
  70. metadata +11 -9
  71. data/app/assets/images/rails_pulse/dashboard.png +0 -0
  72. data/app/assets/images/rails_pulse/query-show.png +0 -0
  73. data/app/assets/images/rails_pulse/request-show.png +0 -0
  74. data/app/assets/images/rails_pulse/request.png +0 -0
  75. data/app/assets/images/rails_pulse/route-show.png +0 -0
  76. data/lib/rails_pulse/extensions/active_record.rb +0 -124
data/README.md CHANGED
@@ -1,164 +1,138 @@
1
1
  <div align="center">
2
- <img src="app/assets/images/rails_pulse/rails-pulse-logo.png" alt="Rails Pulse" width="200" />
2
+ <img src="app/assets/images/rails_pulse/rails-pulse-logo.png" alt="Rails Pulse" width="160" />
3
3
  </div>
4
4
 
5
- ---
6
-
7
5
  # Rails Pulse
8
6
 
9
- **Self-hosted performance monitoring for Rails apps**
7
+ **Performance monitoring that lives inside your Rails app.** Slow requests, N+1 queries, background jobs and exceptions, stored in your own database. No agent, no account, no data leaving your servers.
10
8
 
11
9
  ![Gem Version](https://img.shields.io/gem/v/rails_pulse)
12
10
  ![Rails Version](https://img.shields.io/badge/Rails-7.2%2B-blue)
11
+ ![Ruby Version](https://img.shields.io/badge/Ruby-3.1%2B-red)
13
12
  ![License](https://img.shields.io/badge/License-MIT-green)
14
- ![Ruby Version](https://img.shields.io/badge/Ruby-3.0%2B-red)
15
13
 
16
- Rails Pulse is a Rails engine that monitors your app's performance from the inside. It tracks slow requests, N+1 queries, SQL performance, background jobs, and unhandled exceptions. All data stays in your own database, no third-party cloud, no SaaS subscription, no data leaving your servers.
14
+ Rails Pulse is a Rails engine. It hooks into the instrumentation Rails already emits, writes what it sees to a handful of tables, and mounts a dashboard that shows you where the time went. Install the gem, run one migration, schedule two jobs, and you have monitoring that works the same on SQLite, PostgreSQL and MySQL.
15
+
16
+
17
+ <picture>
18
+ <source media="(prefers-color-scheme: dark)" srcset=".github/images/dashboard-dark.png">
19
+ <img src=".github/images/dashboard-light.png" alt="Rails Pulse dashboard: health bar for routes, queries, jobs, exceptions and storage; P95 response time, request rate and error rate with sparklines; response time percentiles against service level objective lines; and a ranked list of jobs and routes needing attention" width="100%">
20
+ </picture>
21
+
22
+ ## What you get
17
23
 
18
- <table border="0">
19
- <tr border="0" style="border:0">
20
- <td border="0" style="border:0">
21
- <img src="app/assets/images/rails_pulse/dashboard.png" alt="Dashboard" width="400" /></td>
22
- <td style="border:0"><img src="app/assets/images/rails_pulse/request-show.png" alt="Request detail" width="400" /></td>
24
+ - **The state of the app in one screen.** A health bar counts healthy, slow and critical routes, queries, jobs and exception groups. A ranked "needs attention" list tells you what to fix first.
25
+ - **Every request, broken down.** Each request stores its route, status, duration and a timeline of the SQL, view, cache, HTTP, mailer and Active Storage operations inside it.
26
+ - **Queries you can act on.** SQL is normalised and fingerprinted, so you see execution counts and P95 per statement shape, N+1 patterns, an EXPLAIN plan and index suggestions.
27
+ - **Jobs and exceptions in the same place.** Duration, queue wait and failure rate for every Active Job class on any adapter. Unhandled exceptions from requests and jobs grouped by class and location, with filtered params and backtraces.
28
+ - **Numbers over time.** Hourly, daily, weekly and monthly summaries with P50, P95 and P99, your service level objectives drawn as lines on the charts, and a marker for every deploy so a regression lines up with the release that caused it.
29
+ - **Built for production.** Tracking is queued off the request thread and dropped rather than blocked under load. If the gem is deployed before its migrations, tracking pauses and tells you what to run. Retention is enforced by age and by row count so the tables never grow without bound.
30
+
31
+ <table>
32
+ <tr>
33
+ <td width="50%" valign="top">
34
+ <picture>
35
+ <source media="(prefers-color-scheme: dark)" srcset=".github/images/request-dark.png">
36
+ <img src=".github/images/request-light.png" alt="Request detail: duration, status and response size, a performance breakdown by database, view and application time, and a request trace showing action, view and database operations on a timeline">
37
+ </picture>
38
+ <p align="center"><sub>A request and where its time went</sub></p>
39
+ </td>
40
+ <td width="50%" valign="top">
41
+ <picture>
42
+ <source media="(prefers-color-scheme: dark)" srcset=".github/images/query-dark.png">
43
+ <img src=".github/images/query-light.png" alt="Query diagnostics: query characteristics, an issue detected, an optimisation suggestion to add a composite index, and the execution plan">
44
+ </picture>
45
+ <p align="center"><sub>Diagnostics and an index suggestion for one query</sub></p>
46
+ </td>
23
47
  </tr>
24
48
  <tr>
25
- <td style="border:0"><img src="app/assets/images/rails_pulse/query-show.png" alt="Query detail" width="400" /></td>
26
- <td style="border:0"><img src="app/assets/images/rails_pulse/route-show.png" alt="Route detail" width="400" /></td>
49
+ <td colspan="2">
50
+ <picture>
51
+ <source media="(prefers-color-scheme: dark)" srcset=".github/images/route-dark.png">
52
+ <img src=".github/images/route-light.png" alt="Route detail: P95 response time, request rate and error rate cards, and a two-week P95 and P99 chart with service level objective lines and vertical deploy markers">
53
+ </picture>
54
+ <p align="center"><sub>One route over two weeks, with objective lines and deploy markers</sub></p>
55
+ </td>
27
56
  </tr>
28
57
  </table>
29
58
 
30
- ## Installation
31
-
32
- Add to your Gemfile:
59
+ ## Quick start
33
60
 
34
61
  ```ruby
35
- gem 'rails_pulse'
62
+ # Gemfile
63
+ gem "rails_pulse"
36
64
  ```
37
65
 
38
- Run the installer:
39
-
40
66
  ```bash
41
67
  bundle install
42
68
  rails generate rails_pulse:install
43
69
  rails db:migrate
44
70
  ```
45
71
 
46
- Mount the dashboard in `config/routes.rb`:
47
-
48
- ```ruby
49
- Rails.application.routes.draw do
50
- mount RailsPulse::Engine => "/rails_pulse"
51
- end
52
- ```
53
-
54
- Schedule the background jobs:
55
-
56
72
  ```ruby
57
- RailsPulse::SummaryJob.perform_later # cron: 5 * * * *
58
- RailsPulse::CleanupJob.perform_later # cron: 0 1 * * *
73
+ # config/routes.rb
74
+ mount RailsPulse::Engine => "/rails_pulse"
59
75
  ```
60
76
 
61
- Your dashboard is now at `http://localhost:3000/rails_pulse`.
62
-
63
- ## Upgrading
64
-
65
- From 0.3.3:
66
-
67
- ```bash
68
- bundle update rails_pulse
69
- rails generate rails_pulse:upgrade
70
- rails db:migrate # or: rails db:migrate:rails_pulse
71
- rails rails_pulse:migrate_routes # required — fills Action and merges same-action paths
72
- ```
73
-
74
- Restart all processes after migrate. This release changes how routes are stored (`method` moves off the route onto each request), so mixed old/new processes are not supported.
75
-
76
- The upgrade generator appends new settings to `config/initializers/rails_pulse.rb` without rewriting what you already set. Review with `git diff` and keep or discard hunks.
77
-
78
- Exception tracking is **off** for existing installs. The generator inserts `config.track_exceptions = false`; set it to `true` after migrating to opt in:
79
-
80
- ```ruby
81
- config.track_exceptions = true
82
- config.capture_exception_params = true # params are filtered via Rails' filter_parameters
77
+ Schedule the summary job hourly and the cleanup job daily with whatever your queue adapter provides. With Solid Queue:
78
+
79
+ ```yaml
80
+ # config/recurring.yml
81
+ production:
82
+ rails_pulse_summary:
83
+ class: RailsPulse::SummaryJob
84
+ schedule: "5 * * * *"
85
+ rails_pulse_cleanup:
86
+ class: RailsPulse::CleanupJob
87
+ schedule: "0 1 * * *"
83
88
  ```
84
89
 
85
- Separate-database hosts: set `schema_dump: false` on the `rails_pulse` entry in `config/database.yml` and delete `db/rails_pulse_structure.sql` if that file exists.
86
-
87
- If you previously added `rails-pulse.js` / `rails-pulse.css` to `config.assets.precompile`, remove those entries — the gem no longer registers dashboard assets with Sprockets (that re-minify OOMs small hosts). Production deploys that use `config.asset_host` or a CDN-only CSP should run `assets:precompile` so `rails_pulse:install_assets` copies digested files into `public/assets`.
90
+ Open `http://localhost:3000/rails_pulse`. That's the whole setup.
88
91
 
89
- Full install guide: [railspulse.com/documentation/installation](https://railspulse.com/documentation/installation)
92
+ Requirements: Ruby 3.1+, Rails 7.1+ (tested on 7.2, 8.0 and 8.1), SQLite, PostgreSQL or MySQL. Until 0.4.0 ships, pin the pre-release with `gem "rails_pulse", "~> 0.4.0.pre"`.
90
93
 
91
- Separate database setup: [railspulse.com/documentation/database](https://railspulse.com/documentation/database)
94
+ Full install guide, including a separate database and plain cron: [railspulse.com/documentation/installation](https://railspulse.com/documentation/installation)
92
95
 
93
- ## Configuration
96
+ ## Going further
94
97
 
95
- Rails Pulse works out of the box with sensible defaults. To customise, edit `config/initializers/rails_pulse.rb`:
98
+ **Lock it down.** The dashboard is authenticated by default outside development and test. Point it at your own auth with a predicate; anything but `true` is a 403.
96
99
 
97
100
  ```ruby
98
101
  RailsPulse.configure do |config|
99
- config.enabled = true
100
-
101
- config.request_thresholds = {
102
- slow: 700,
103
- very_slow: 2000,
104
- critical: 4000
105
- }
106
-
107
- config.track_jobs = true
108
- config.capture_job_arguments = false # keep false to protect sensitive data
109
-
110
- config.track_exceptions = true
111
- config.capture_exception_params = true # params are filtered via Rails' filter_parameters
112
-
113
- config.full_retention_period = 30.days
102
+ config.authorize = ->(controller) { controller.current_user&.admin? }
114
103
  end
115
104
  ```
116
105
 
117
- Full configuration reference: [railspulse.com/documentation/advanced](https://railspulse.com/documentation/advanced)
106
+ With nothing configured it falls back to HTTP Basic against `RAILS_PULSE_USERNAME` and `RAILS_PULSE_PASSWORD`. [Authentication guide](https://railspulse.com/documentation/authentication)
118
107
 
119
- ## Authentication
108
+ **Tune it.** Thresholds for slow, very slow and critical, service level objectives per percentile, what to ignore, what to tag, how long to keep. All in `config/initializers/rails_pulse.rb`. [Configuration reference](https://railspulse.com/documentation/advanced)
120
109
 
121
- Rails Pulse has no built-in user accounts; you protect the dashboard using your app's existing auth. Authentication is on by default outside development and test, and with nothing configured it falls back to HTTP Basic against `RAILS_PULSE_USERNAME` / `RAILS_PULSE_PASSWORD` (denying everything if the password is unset).
110
+ **Run the dashboard on its own.** `bundle exec rails_pulse_server` serves the UI from a separate process with its own health endpoint, so a slow report never competes with your app for a thread. [Deployment modes](https://railspulse.com/documentation/deployment-modes)
122
111
 
123
- The simplest hook is a predicate that receives the controller and returns `true` to allow — anything else is a 403:
124
-
125
- ```ruby
126
- RailsPulse.configure do |config|
127
- config.authorize = ->(controller) { controller.current_user&.admin? }
128
- end
129
- ```
112
+ **Mark your deploys.** `rails rails_pulse:record_deployment[sha]` from a release script, or `POST /rails_pulse/deployments` with a token from CI, and every chart draws a line at that moment.
130
113
 
131
- If you need to redirect to a login page instead, use `authentication_method`, which runs inside the controller and denies by rendering or redirecting:
114
+ **Keep it in its own database.** `rails generate rails_pulse:install --database=separate` puts the tables somewhere your primary never has to vacuum. [Database setup](https://railspulse.com/documentation/database)
132
115
 
133
- ```ruby
134
- RailsPulse.configure do |config|
135
- config.authentication_redirect_path = "/login"
116
+ ## Upgrading
136
117
 
137
- config.authentication_method = proc {
138
- unless user_signed_in? && current_user.admin?
139
- redirect_to main_app.root_path, alert: "Access denied"
140
- end
141
- }
142
- end
118
+ ```bash
119
+ bundle update rails_pulse
120
+ rails generate rails_pulse:upgrade
121
+ rails db:migrate # separate Pulse database: rails db:migrate:rails_pulse
122
+ rails rails_pulse:status # exits 1 while anything still needs action
143
123
  ```
144
124
 
145
- Returning `false` from `authentication_method` without responding is also treated as a denial, but `nil` (what `unless … end` returns on success) allows the request — so keep predicate-style checks in `authorize`.
146
-
147
- Authentication guide: [railspulse.com/documentation/authentication](https://railspulse.com/documentation/authentication)
148
-
149
- ## Features
150
-
151
- - **Request monitoring** — every request is timed and stored with its route, status, SQL count, and duration. Slow requests are flagged automatically based on thresholds you control.
152
- - **Query analysis** — captures the queries behind each request, detects N+1 patterns, and tracks normalized SQL across requests so you can see which queries are hurting you in production, not just in development.
153
- - **Job tracking** — monitors background job duration, queue wait time, and failure rates. Works with any Active Job adapter.
154
- - **Exception tracking** — captures unhandled exceptions from web requests and background jobs, groups them by class and location, and shows full backtraces with filtered request params. See recurring errors in production without a separate error monitoring service.
155
- - **System health bar** — at-a-glance dashboard summary showing healthy, slow, and critical counts across your routes, queries, jobs, and storage. Lets you see the overall state of your app before drilling into specifics.
156
- - **No data leaves your app** — everything is stored in your own database. No third-party cloud, no SaaS subscription, no outbound connections.
157
- - **Low overhead** — tracking is async and uses a thread-local flag to skip recording Rails Pulse's own internal requests.
125
+ Upgrading from 0.3.x to 0.4? **Back up first**, run `rails rails_pulse:migrate_routes` after migrating, and restart every process together. The details are in the [changelog](CHANGELOG.md).
158
126
 
159
127
  ## Contributing
160
128
 
161
- Bug reports and pull requests are welcome on [GitHub](https://github.com/railspulse/rails_pulse).
129
+ Bug reports and pull requests are welcome on [GitHub](https://github.com/railspulse/rails_pulse). `docs/` explains how the pieces fit and why they are built the way they are.
130
+
131
+ ```bash
132
+ git config core.hooksPath .githooks # once, after cloning
133
+ DB=sqlite3 rake test # or DB=postgresql / DB=mysql2
134
+ bundle exec rubocop
135
+ ```
162
136
 
163
137
  ## License
164
138
 
@@ -24,10 +24,11 @@ module SessionFiltersConcern
24
24
  session_global_filters["disabled_tags"] || []
25
25
  end
26
26
 
27
- # Returns the time range preference from session
28
- # Can be a symbol/string for presets or a hash for custom ranges
27
+ # Returns the time range preference from session, normalized so every
28
+ # session serializer yields the same shape: a String preset name, a
29
+ # string-keyed Hash for a custom range, or nil for anything unrecognized.
29
30
  def session_time_range_preference
30
- session[:time_range_preference]
31
+ RailsPulse::TimeRangePreference.normalize(session[:time_range_preference])
31
32
  end
32
33
 
33
34
  # Returns whether deployment markers should be shown by default
@@ -41,6 +41,11 @@ module TimeRangeConcern
41
41
  end_time = Time.zone.now
42
42
  selected_time_range = default_key
43
43
 
44
+ # Normalized up front so a symbol-keyed hash from a Marshal-backed session
45
+ # store reads the same as the string-keyed one the cookie store produces;
46
+ # anything unrecognized becomes nil and falls through to later priorities.
47
+ session_preference = RailsPulse::TimeRangePreference.normalize(session[:time_range_preference])
48
+
44
49
  # Priority 1: Page-specific preset from dropdown (check this first!)
45
50
  if ransack_params[:period_start_range].present? && ransack_params[:period_start_range].to_sym != :custom
46
51
  # Predefined time range from dropdown
@@ -76,25 +81,22 @@ module TimeRangeConcern
76
81
  selected_time_range = :custom
77
82
  end
78
83
  # Priority 4: Time range selector (from session)
79
- elsif session[:time_range_preference].present?
80
- preference = session[:time_range_preference]
81
- if preference.is_a?(Hash) && preference["type"] == "custom"
82
- # Custom range from time range selector
83
- start_time = parse_time_param(preference["start_time"]) || start_time
84
- end_time = parse_time_param(preference["end_time"]) || end_time
85
- selected_time_range = :custom
86
- else
87
- # Preset from time range selector
88
- selected_time_range = preference.to_sym
89
- start_time =
90
- case selected_time_range
91
- when :last_24_hours then 1.day.ago
92
- when :last_7_days then 1.week.ago
93
- when :last_14_days then 2.weeks.ago
94
- when :last_30_days then 1.month.ago
95
- else start_time
96
- end
97
- end
84
+ elsif RailsPulse::TimeRangePreference.custom?(session_preference)
85
+ # Custom range from time range selector
86
+ start_time = parse_time_param(session_preference["start_time"]) || start_time
87
+ end_time = parse_time_param(session_preference["end_time"]) || end_time
88
+ selected_time_range = :custom
89
+ elsif session_preference.is_a?(String) && session_preference.present?
90
+ # Preset from time range selector
91
+ selected_time_range = session_preference.to_sym
92
+ start_time =
93
+ case selected_time_range
94
+ when :last_24_hours then 1.day.ago
95
+ when :last_7_days then 1.week.ago
96
+ when :last_14_days then 2.weeks.ago
97
+ when :last_30_days then 1.month.ago
98
+ else start_time
99
+ end
98
100
  # Priority 5: Global filters (from session)
99
101
  elsif session_global_filters["start_time"].present? || session_global_filters["end_time"].present?
100
102
  start_time = parse_time_param(session_global_filters["start_time"]) || start_time
@@ -105,6 +107,14 @@ module TimeRangeConcern
105
107
 
106
108
  time_diff = (end_time.to_i - start_time.to_i) / 3600.0
107
109
 
110
+ # in_time_zone before rounding: a parsed custom-range string carries the
111
+ # server OS's local offset, not Time.zone, and beginning_of_day/_hour
112
+ # round in whatever offset the receiver has. Summary data is always
113
+ # bucketed by Time.zone, so without this the boundary can miss every
114
+ # summary row and render charts empty. No-op for already-Time.zone values.
115
+ start_time = start_time.in_time_zone
116
+ end_time = end_time.in_time_zone
117
+
108
118
  if time_diff <= 25
109
119
  start_time = start_time.beginning_of_hour
110
120
  end_time = end_time.end_of_hour
@@ -15,6 +15,7 @@ module RailsPulse
15
15
  protect_from_forgery with: :exception
16
16
 
17
17
  before_action :authenticate_rails_pulse_user!
18
+ before_action :require_current_schema!
18
19
  before_action :set_onboarding_state
19
20
  before_action :load_deployment_markers
20
21
 
@@ -85,10 +86,12 @@ module RailsPulse
85
86
  session[:time_range_preference] = preset if TIME_RANGE_PRESETS.include?(preset)
86
87
  elsif start_time && end_time
87
88
  # Store custom range
89
+ # String keys: a Marshal-backed session store would otherwise hand the
90
+ # symbol keys back verbatim, and the readers expect the JSON shape.
88
91
  session[:time_range_preference] = {
89
- type: "custom",
90
- start_time: start_time,
91
- end_time: end_time
92
+ "type" => "custom",
93
+ "start_time" => start_time,
94
+ "end_time" => end_time
92
95
  }
93
96
  end
94
97
 
@@ -132,6 +135,8 @@ module RailsPulse
132
135
  config = RailsPulse.configuration
133
136
  return unless config.authentication_enabled
134
137
 
138
+ return authenticate_standalone!(config) if RailsPulse.standalone?
139
+
135
140
  if config.authentication_method.nil? && config.authorize.nil?
136
141
  return fallback_http_basic_auth
137
142
  end
@@ -144,7 +149,41 @@ module RailsPulse
144
149
  run_authorize_predicate(config.authorize) if config.authorize
145
150
  rescue StandardError => e
146
151
  logger.warn "RailsPulse authentication failed: #{e.message}"
147
- redirect_to config.authentication_redirect_path
152
+ # The redirect target is a host app page; from the standalone server
153
+ # it would point at a route this process does not serve.
154
+ if RailsPulse.standalone?
155
+ render plain: "Forbidden", status: :forbidden
156
+ else
157
+ redirect_to config.authentication_redirect_path
158
+ end
159
+ end
160
+
161
+ # The standalone server has the host's models but not its middleware:
162
+ # no host session store, Warden, or Devise helpers, and a different
163
+ # hostname so no shared cookie. A session-based `authentication_method`
164
+ # or `authorize` would raise (rescued above into a useless redirect) or,
165
+ # worse, be written so that it silently allows. Ignore both here; use
166
+ # `standalone_authentication_method` when set, else HTTP Basic.
167
+ def authenticate_standalone!(config)
168
+ hook = config.standalone_authentication_method
169
+ if hook.nil?
170
+ note_ignored_host_authentication(config)
171
+ return fallback_http_basic_auth
172
+ end
173
+
174
+ run_authentication_method(hook)
175
+ end
176
+
177
+ # Logged once per process, not once per controller: the flag lives on
178
+ # RailsPulse::Standalone, so RequestsController and QueriesController do
179
+ # not each repeat it on their first request.
180
+ def note_ignored_host_authentication(config)
181
+ return if config.authentication_method.nil? && config.authorize.nil?
182
+ return unless RailsPulse.note_host_authentication_ignored!
183
+
184
+ logger.info "RailsPulse: standalone dashboard ignores config.authentication_method / config.authorize " \
185
+ "(host session helpers are not available here); using HTTP Basic auth. " \
186
+ "Set config.standalone_authentication_method to customise."
148
187
  end
149
188
 
150
189
  def run_authentication_method(hook)
@@ -196,6 +235,30 @@ module RailsPulse
196
235
  end
197
236
  end
198
237
 
238
+ # New gem code against un-migrated tables would otherwise 500 on every
239
+ # page that touches a missing column. Runs after authentication so the
240
+ # schema report is not exposed to anonymous callers.
241
+ def require_current_schema!
242
+ return if RailsPulse::SchemaCheck.current?
243
+
244
+ RailsPulse::SchemaCheck.warn_once!
245
+ missing = RailsPulse::SchemaCheck.missing
246
+
247
+ respond_to do |format|
248
+ format.html do
249
+ render template: "rails_pulse/shared/schema_outdated", layout: false,
250
+ status: :service_unavailable, locals: { missing: missing }
251
+ end
252
+ format.any do
253
+ render json: {
254
+ error: "Rails Pulse #{RailsPulse::VERSION} requires a schema upgrade",
255
+ missing: missing,
256
+ instructions: RailsPulse::SchemaCheck::UPGRADE_INSTRUCTIONS
257
+ }, status: :service_unavailable
258
+ end
259
+ end
260
+ end
261
+
199
262
  def load_deployment_markers
200
263
  @deployment_markers = []
201
264
  @show_deployment_markers = session_show_deployment_markers
@@ -8,8 +8,11 @@ module RailsPulse
8
8
  @start_time, @end_time, @selected_time_range, @time_diff = setup_time_range
9
9
  populate_deployment_markers
10
10
 
11
- # Convert time range to period in days for dashboard cards/charts
12
- @period = ((@end_time - @start_time) / 1.day).round
11
+ # Rounded rather than truncated, so a range a few seconds short of N
12
+ # whole days still counts as N. Cards/charts also get @start_time/
13
+ # @end_time directly so they bucket the exact range, not "the last
14
+ # @period days ending now".
15
+ @period = RailsPulse::TimeWindow.new(@start_time, @end_time).days
13
16
 
14
17
  # Determine period type based on time range
15
18
  # If 24 hours or less, use hourly summaries, otherwise use daily
@@ -19,22 +22,28 @@ module RailsPulse
19
22
  disabled_tags = session_disabled_tags
20
23
  show_non_tagged = session[:show_non_tagged] != false
21
24
 
22
- @percentile_response_times_metric_card = RailsPulse::Routes::Cards::PercentileResponseTimes.new(route: nil, disabled_tags: disabled_tags, show_non_tagged: show_non_tagged, period: @period, period_type: @period_type).to_metric_card
23
- @request_count_totals_metric_card = RailsPulse::Routes::Cards::RequestCountTotals.new(route: nil, disabled_tags: disabled_tags, show_non_tagged: show_non_tagged, period: @period, period_type: @period_type).to_metric_card
24
- @error_rates_metric_card = RailsPulse::Routes::Cards::ErrorRates.new(route: nil, disabled_tags: disabled_tags, show_non_tagged: show_non_tagged, period: @period, period_type: @period_type).to_metric_card
25
- @job_failure_rate_metric_card = RailsPulse::Jobs::Cards::FailureRate.new(period: @period, period_type: @period_type).to_metric_card if RailsPulse.configuration.track_jobs
25
+ card_and_chart_options = {
26
+ disabled_tags: disabled_tags, show_non_tagged: show_non_tagged,
27
+ period: @period, period_type: @period_type,
28
+ start_time: @start_time, end_time: @end_time
29
+ }
30
+
31
+ @percentile_response_times_metric_card = RailsPulse::Routes::Cards::PercentileResponseTimes.new(route: nil, **card_and_chart_options).to_metric_card
32
+ @request_count_totals_metric_card = RailsPulse::Routes::Cards::RequestCountTotals.new(route: nil, **card_and_chart_options).to_metric_card
33
+ @error_rates_metric_card = RailsPulse::Routes::Cards::ErrorRates.new(route: nil, **card_and_chart_options).to_metric_card
34
+ @job_failure_rate_metric_card = RailsPulse::Jobs::Cards::FailureRate.new(**card_and_chart_options).to_metric_card if RailsPulse.configuration.track_jobs
26
35
 
27
36
  # Generate chart data for inline rendering
28
- @response_time_percentiles_chart_data = RailsPulse::Dashboard::Charts::ResponseTimePercentiles.new(disabled_tags: disabled_tags, show_non_tagged: show_non_tagged, period: @period, period_type: @period_type).to_chart_data
29
- @throughput_and_errors_chart_data = RailsPulse::Dashboard::Charts::ThroughputAndErrors.new(disabled_tags: disabled_tags, show_non_tagged: show_non_tagged, period: @period, period_type: @period_type).to_chart_data
37
+ @response_time_percentiles_chart_data = RailsPulse::Dashboard::Charts::ResponseTimePercentiles.new(**card_and_chart_options).to_chart_data
38
+ @throughput_and_errors_chart_data = RailsPulse::Dashboard::Charts::ThroughputAndErrors.new(**card_and_chart_options).to_chart_data
30
39
 
31
40
  # Needs Attention panel
32
- @needs_attention = RailsPulse::Dashboard::NeedsAttention.new(disabled_tags: disabled_tags, show_non_tagged: show_non_tagged, period: @period).to_attention_data
41
+ @needs_attention = RailsPulse::Dashboard::NeedsAttention.new(disabled_tags: disabled_tags, show_non_tagged: show_non_tagged, period: @period, start_time: @start_time, end_time: @end_time).to_attention_data
33
42
 
34
43
  # System Health bar
35
- @health_summary = RailsPulse::Dashboard::HealthSummary.new(disabled_tags: disabled_tags, show_non_tagged: show_non_tagged, period: @period).to_health_data
44
+ @health_summary = RailsPulse::Dashboard::HealthSummary.new(disabled_tags: disabled_tags, show_non_tagged: show_non_tagged, period: @period, start_time: @start_time, end_time: @end_time).to_health_data
36
45
 
37
- @storage_status = RailsPulse::Dashboard::StorageStatus.new
46
+ @storage_status = RailsPulse::Dashboard::StorageStatus.new(cached_sizes: true)
38
47
 
39
48
  # Deployments panel — scoped to the same window as the chart markers so
40
49
  # the panel and the markers drawn on the charts always agree.
@@ -36,10 +36,7 @@ module RailsPulse
36
36
  end
37
37
 
38
38
  def finish
39
- deployment = RailsPulse::Deployment
40
- .where(revision: finish_params[:revision])
41
- .order(started_at: :desc)
42
- .first
39
+ deployment = RailsPulse::Deployment.latest_for_revision(finish_params[:revision])
43
40
 
44
41
  return render json: { status: "error", error: "Deployment not found" }, status: :not_found unless deployment
45
42
 
@@ -14,6 +14,7 @@ module RailsPulse
14
14
  def show
15
15
  setup_metric_cards
16
16
  setup_chart_and_table_data
17
+ setup_archived_summary_data
17
18
  end
18
19
 
19
20
  private
@@ -126,6 +127,51 @@ module RailsPulse
126
127
  @route = Route.find(params[:id])
127
128
  end
128
129
 
130
+ ARCHIVED_SUMMARY_PAGE_LIMIT = 20
131
+
132
+ # Raw Request rows get purged by CleanupService after full_retention_period.
133
+ # For any part of the selected window older than that cutoff, fall back to
134
+ # pre-aggregated Summary rows (which CleanupService never deletes for
135
+ # day/week/month periods) so the table isn't just blank for old ranges.
136
+ def setup_archived_summary_data
137
+ cutoff = retention_cutoff
138
+ window_start = @page_timings&.table_start_time
139
+
140
+ scope = if cutoff && window_start && Time.at(window_start) < cutoff
141
+ scope = Summary.for_routes
142
+ .where(summarizable_id: @route.id)
143
+ .where(period_type: period_type)
144
+ .where("period_start < ?", cutoff)
145
+
146
+ if @page_timings&.table_end_time
147
+ scope = scope.where("period_start < ?", Time.at(@page_timings.table_end_time))
148
+ end
149
+
150
+ scope.order(period_start: :desc)
151
+ else
152
+ Summary.none
153
+ end
154
+
155
+ @archived_pagination, @archived_summary_data =
156
+ paginate_archived(scope, limit: ARCHIVED_SUMMARY_PAGE_LIMIT)
157
+ end
158
+
159
+ # Mirrors PaginationConcern#paginate but keys off its own `archived_page`
160
+ # param so the archived table's pagination doesn't fight over the same
161
+ # `page`/`limit` params as the live requests table above it on the page.
162
+ def paginate_archived(collection, limit:)
163
+ page = [ params[:archived_page].to_i, 1 ].max
164
+ paginator = RailsPulse::Paginator.new(count: collection.count(:all), page: page, limit: limit)
165
+ records = collection.offset((paginator.page - 1) * limit).limit(limit)
166
+ [ paginator, records ]
167
+ end
168
+
169
+ def retention_cutoff
170
+ config = RailsPulse.configuration rescue nil
171
+ period = config&.full_retention_period
172
+ period ? period.ago : nil
173
+ end
174
+
129
175
  def ordering_by_computed_column?
130
176
  # Check if we're ordering by status_indicator (computed column)
131
177
  @ransack_query.sorts.any? { |sort| sort.name == "status_indicator" }
@@ -31,5 +31,9 @@ module RailsPulse
31
31
  def page_url(page_number)
32
32
  url_for(request.query_parameters.merge(page: page_number))
33
33
  end
34
+
35
+ def archived_page_url(page_number)
36
+ url_for(request.query_parameters.merge(archived_page: page_number, anchor: "archived-data"))
37
+ end
34
38
  end
35
39
  end
@@ -30,13 +30,16 @@ module RailsPulse
30
30
  # Only keep segments after the mount point
31
31
  path_segments = path_segments[(mount_end_index + 1)..-1]
32
32
 
33
- # Build the engine root path directly from mount segments (avoids relying on named route helper)
34
- engine_root = "/" + mount_segments.join("/")
33
+ # Build the engine root path directly from mount segments (avoids relying on named route helper).
34
+ # Served at "/" (standalone server, or a host that mounts the engine at root) the prefix
35
+ # is empty, not "/": segments are appended as "/#{segment}", and "/" + "/queries" would be
36
+ # "//queries" — a protocol-relative URL that browsers resolve to a host called "queries".
37
+ engine_root = mount_segments.empty? ? "" : "/" + mount_segments.join("/")
35
38
 
36
39
  # Start with the Home link
37
40
  crumbs = [ {
38
41
  title: "Home",
39
- path: engine_root,
42
+ path: engine_root.empty? ? "/" : engine_root,
40
43
  current: path_segments.empty?
41
44
  } ]
42
45
 
@@ -160,6 +160,12 @@ export default class extends Controller {
160
160
  config.xAxis = config.xAxis || {}
161
161
  if (isTimePairs) {
162
162
  config.xAxis.type = 'time'
163
+ // Without this, ECharts spaces ticks by pixel width, not by the data's
164
+ // actual bucket size — a multi-day "time" axis can end up with more
165
+ // ticks than days, and since the label formatter only shows the date,
166
+ // adjacent ticks on the same day render as duplicate-looking labels.
167
+ const bucketMs = this._minTimestampGapMs(data.series)
168
+ if (bucketMs) config.xAxis.minInterval = bucketMs
163
169
  } else {
164
170
  config.xAxis.type = 'category'
165
171
  config.xAxis.data = data.labels
@@ -240,6 +246,27 @@ export default class extends Controller {
240
246
  return Array.isArray(firstPoint) || Array.isArray(firstPoint?.value)
241
247
  }
242
248
 
249
+ // Smallest gap (ms) between any two distinct timestamps across all series,
250
+ // used as xAxis.minInterval so auto-placed ticks never fall closer together
251
+ // than the data actually does.
252
+ _minTimestampGapMs(series) {
253
+ const timestamps = new Set()
254
+ series.forEach(s => {
255
+ (s.data || []).forEach(point => {
256
+ const pair = Array.isArray(point) ? point : (Array.isArray(point?.value) ? point.value : null)
257
+ if (pair && typeof pair[0] === 'number') timestamps.add(pair[0])
258
+ })
259
+ })
260
+
261
+ const sorted = Array.from(timestamps).sort((a, b) => a - b)
262
+ let minGap = null
263
+ for (let i = 1; i < sorted.length; i++) {
264
+ const gap = sorted[i] - sorted[i - 1]
265
+ if (gap > 0 && (minGap === null || gap < minGap)) minGap = gap
266
+ }
267
+ return minGap
268
+ }
269
+
243
270
  deploymentMarkerSeriesId = 'rails-pulse-deployment-markers'
244
271
 
245
272
  _buildDeploymentMarkerSeries(markers, visible = true) {