rails_pulse 0.4.2 → 0.5.0.pre.1

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 (167) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +33 -1
  3. data/README.md +15 -4
  4. data/app/assets/stylesheets/rails_pulse/components/chart.css +11 -0
  5. data/app/assets/stylesheets/rails_pulse/components/descriptive_list.css +19 -1
  6. data/app/assets/stylesheets/rails_pulse/components/row.css +3 -17
  7. data/app/controllers/concerns/chart_table_concern.rb +28 -69
  8. data/app/controllers/concerns/deployment_markers_concern.rb +3 -1
  9. data/app/controllers/concerns/metric_card_concern.rb +6 -7
  10. data/app/controllers/rails_pulse/api/v1/base_controller.rb +130 -0
  11. data/app/controllers/rails_pulse/api/v1/capabilities_controller.rb +27 -0
  12. data/app/controllers/rails_pulse/api/v1/coverage_controller.rb +158 -0
  13. data/app/controllers/rails_pulse/api/v1/deployments_controller.rb +24 -0
  14. data/app/controllers/rails_pulse/api/v1/exceptions_controller.rb +76 -0
  15. data/app/controllers/rails_pulse/api/v1/job_runs_controller.rb +34 -0
  16. data/app/controllers/rails_pulse/api/v1/jobs_controller.rb +172 -0
  17. data/app/controllers/rails_pulse/api/v1/queries_controller.rb +114 -0
  18. data/app/controllers/rails_pulse/api/v1/requests_controller.rb +58 -0
  19. data/app/controllers/rails_pulse/api/v1/routes_controller.rb +98 -0
  20. data/app/controllers/rails_pulse/application_controller.rb +1 -1
  21. data/app/controllers/rails_pulse/dashboard_controller.rb +20 -20
  22. data/app/controllers/rails_pulse/deployments_controller.rb +23 -13
  23. data/app/controllers/rails_pulse/exceptions_controller.rb +10 -12
  24. data/app/controllers/rails_pulse/jobs_controller.rb +1 -2
  25. data/app/controllers/rails_pulse/queries_controller.rb +6 -5
  26. data/app/controllers/rails_pulse/requests_controller.rb +15 -14
  27. data/app/controllers/rails_pulse/routes_controller.rb +4 -8
  28. data/app/helpers/rails_pulse/application_helper.rb +15 -0
  29. data/app/helpers/rails_pulse/chart_helper.rb +9 -2
  30. data/app/helpers/rails_pulse/formatting_helper.rb +11 -12
  31. data/app/javascript/rails_pulse/controllers/chart_controller.js +80 -43
  32. data/app/jobs/rails_pulse/backfill_summaries_job.rb +4 -2
  33. data/app/models/concerns/rails_pulse/has_metadata.rb +15 -0
  34. data/app/models/concerns/rails_pulse/taggable.rb +5 -12
  35. data/app/models/rails_pulse/cards/base.rb +20 -11
  36. data/app/models/rails_pulse/charts/base.rb +2 -6
  37. data/app/models/rails_pulse/charts/percentile_chart_base.rb +3 -4
  38. data/app/models/rails_pulse/dashboard/charts/response_time_percentiles.rb +4 -5
  39. data/app/models/rails_pulse/dashboard/charts/throughput_and_errors.rb +4 -5
  40. data/app/models/rails_pulse/dashboard/concerns/time_range_helper.rb +1 -2
  41. data/app/models/rails_pulse/dashboard/health_summary.rb +30 -3
  42. data/app/models/rails_pulse/dashboard/needs_attention.rb +2 -3
  43. data/app/models/rails_pulse/dashboard/storage_pressure.rb +34 -4
  44. data/app/models/rails_pulse/dashboard/storage_status.rb +36 -0
  45. data/app/models/rails_pulse/deployment.rb +2 -7
  46. data/app/models/rails_pulse/event.rb +40 -0
  47. data/app/models/rails_pulse/exception_occurrence.rb +1 -1
  48. data/app/models/rails_pulse/exceptions/cards/exception_rate.rb +3 -6
  49. data/app/models/rails_pulse/exceptions/cards/open_groups.rb +1 -4
  50. data/app/models/rails_pulse/exceptions/cards/total_occurrences.rb +3 -6
  51. data/app/models/rails_pulse/exceptions/charts/occurrence_volume.rb +4 -5
  52. data/app/models/rails_pulse/job_run.rb +1 -1
  53. data/app/models/rails_pulse/jobs/cards/failure_rate.rb +8 -14
  54. data/app/models/rails_pulse/jobs/cards/p95_duration.rb +8 -14
  55. data/app/models/rails_pulse/jobs/cards/total_runs.rb +5 -11
  56. data/app/models/rails_pulse/jobs/charts/duration.rb +1 -1
  57. data/app/models/rails_pulse/jobs/charts/execution_volume.rb +1 -1
  58. data/app/models/rails_pulse/jobs/charts/failure_rate.rb +1 -1
  59. data/app/models/rails_pulse/like_pattern.rb +29 -0
  60. data/app/models/rails_pulse/queries/cards/average_query_times.rb +6 -12
  61. data/app/models/rails_pulse/queries/cards/database_load.rb +0 -9
  62. data/app/models/rails_pulse/queries/cards/execution_rate.rb +5 -11
  63. data/app/models/rails_pulse/queries/cards/percentile_query_times.rb +6 -12
  64. data/app/models/rails_pulse/queries/charts/database_load.rb +5 -6
  65. data/app/models/rails_pulse/queries/charts/execution_volume.rb +1 -1
  66. data/app/models/rails_pulse/request.rb +1 -1
  67. data/app/models/rails_pulse/routes/cards/error_rates.rb +5 -15
  68. data/app/models/rails_pulse/routes/cards/percentile_response_times.rb +5 -17
  69. data/app/models/rails_pulse/routes/cards/request_count_totals.rb +5 -17
  70. data/app/models/rails_pulse/routes/charts/error_rate.rb +1 -1
  71. data/app/models/rails_pulse/routes/charts/request_volume.rb +1 -1
  72. data/app/models/rails_pulse/summary.rb +5 -0
  73. data/app/models/rails_pulse/tables/base.rb +5 -6
  74. data/app/models/rails_pulse/time_range.rb +309 -0
  75. data/app/models/rails_pulse/writer_heartbeat.rb +86 -0
  76. data/app/serializers/rails_pulse/api/v1/deployment_serializer.rb +20 -0
  77. data/app/serializers/rails_pulse/api/v1/exception_group_serializer.rb +23 -0
  78. data/app/serializers/rails_pulse/api/v1/exception_occurrence_serializer.rb +22 -0
  79. data/app/serializers/rails_pulse/api/v1/job_run_serializer.rb +28 -0
  80. data/app/serializers/rails_pulse/api/v1/job_serializer.rb +26 -0
  81. data/app/serializers/rails_pulse/api/v1/query_serializer.rb +25 -0
  82. data/app/serializers/rails_pulse/api/v1/request_serializer.rb +21 -0
  83. data/app/serializers/rails_pulse/api/v1/route_serializer.rb +19 -0
  84. data/app/services/rails_pulse/exception_capture_service.rb +1 -1
  85. data/app/services/rails_pulse/operations/comparison.rb +1 -1
  86. data/app/services/rails_pulse/summary_service/from_child_periods.rb +161 -0
  87. data/app/services/rails_pulse/summary_service/from_raw_rows.rb +115 -0
  88. data/app/services/rails_pulse/summary_service/metrics.rb +38 -0
  89. data/app/services/rails_pulse/summary_service.rb +121 -133
  90. data/app/services/rails_pulse/tag_filter_service.rb +5 -5
  91. data/app/views/layouts/rails_pulse/_time_range_selector.html.erb +4 -1
  92. data/app/views/rails_pulse/components/_operation_details_popover.html.erb +1 -1
  93. data/app/views/rails_pulse/components/_table_head.html.erb +2 -2
  94. data/app/views/rails_pulse/dashboard/_chart_panel.html.erb +6 -1
  95. data/app/views/rails_pulse/dashboard/_deployments_panel.html.erb +2 -2
  96. data/app/views/rails_pulse/dashboard/_health_badge.html.erb +29 -20
  97. data/app/views/rails_pulse/dashboard/index.html.erb +16 -2
  98. data/app/views/rails_pulse/jobs/_chart_tabs.html.erb +1 -0
  99. data/app/views/rails_pulse/jobs/index.html.erb +1 -1
  100. data/app/views/rails_pulse/queries/_chart_tabs.html.erb +1 -0
  101. data/app/views/rails_pulse/queries/index.html.erb +1 -1
  102. data/app/views/rails_pulse/queries/show.html.erb +1 -1
  103. data/app/views/rails_pulse/requests/index.html.erb +1 -1
  104. data/app/views/rails_pulse/routes/_chart_tabs.html.erb +1 -0
  105. data/app/views/rails_pulse/routes/index.html.erb +1 -1
  106. data/app/views/rails_pulse/routes/show.html.erb +1 -1
  107. data/app/views/rails_pulse/shared/_aggregation_zone_badge.html.erb +4 -0
  108. data/app/views/rails_pulse/storage/show.html.erb +37 -0
  109. data/config/routes.rb +19 -0
  110. data/db/rails_pulse_migrate/20260506000001_create_rails_pulse_exceptions.rb +1 -1
  111. data/db/rails_pulse_migrate/20260921000001_create_rails_pulse_events.rb +21 -0
  112. data/db/rails_pulse_schema.rb +20 -2
  113. data/exe/rails-pulse +7 -0
  114. data/lib/generators/rails_pulse/base_methods.rb +1 -0
  115. data/lib/generators/rails_pulse/templates/db/rails_pulse_schema.rb +20 -2
  116. data/lib/generators/rails_pulse/templates/rails_pulse.rb +36 -13
  117. data/lib/rails_pulse/cleanup_service.rb +26 -20
  118. data/lib/rails_pulse/cli/agent_files/agents.md +87 -0
  119. data/lib/rails_pulse/cli/agent_files/claude_skill.md +165 -0
  120. data/lib/rails_pulse/cli/base_command.rb +37 -0
  121. data/lib/rails_pulse/cli/client.rb +64 -0
  122. data/lib/rails_pulse/cli/config.rb +87 -0
  123. data/lib/rails_pulse/cli/configure.rb +75 -0
  124. data/lib/rails_pulse/cli/coverage.rb +91 -0
  125. data/lib/rails_pulse/cli/deployments.rb +40 -0
  126. data/lib/rails_pulse/cli/exceptions.rb +111 -0
  127. data/lib/rails_pulse/cli/formatter.rb +31 -0
  128. data/lib/rails_pulse/cli/install.rb +145 -0
  129. data/lib/rails_pulse/cli/job_runs.rb +53 -0
  130. data/lib/rails_pulse/cli/jobs.rb +71 -0
  131. data/lib/rails_pulse/cli/main.rb +72 -0
  132. data/lib/rails_pulse/cli/mcp.rb +47 -0
  133. data/lib/rails_pulse/cli/queries.rb +62 -0
  134. data/lib/rails_pulse/cli/requests.rb +49 -0
  135. data/lib/rails_pulse/cli/routes.rb +66 -0
  136. data/lib/rails_pulse/configuration.rb +23 -1
  137. data/lib/rails_pulse/current.rb +11 -0
  138. data/lib/rails_pulse/engine.rb +12 -3
  139. data/lib/rails_pulse/job_run_collector.rb +15 -15
  140. data/lib/rails_pulse/mcp/server.rb +76 -0
  141. data/lib/rails_pulse/mcp/tools/coverage.rb +96 -0
  142. data/lib/rails_pulse/mcp/tools/deployments.rb +80 -0
  143. data/lib/rails_pulse/mcp/tools/endpoint.rb +167 -0
  144. data/lib/rails_pulse/mcp/tools/errors.rb +95 -0
  145. data/lib/rails_pulse/mcp/tools/exception_detail.rb +118 -0
  146. data/lib/rails_pulse/mcp/tools/exceptions.rb +114 -0
  147. data/lib/rails_pulse/mcp/tools/helpers.rb +162 -0
  148. data/lib/rails_pulse/mcp/tools/jobs.rb +155 -0
  149. data/lib/rails_pulse/mcp/tools/queries.rb +136 -0
  150. data/lib/rails_pulse/mcp/tools/routes.rb +97 -0
  151. data/lib/rails_pulse/mcp/tools/slow_requests.rb +118 -0
  152. data/lib/rails_pulse/middleware/request_collector.rb +9 -9
  153. data/lib/rails_pulse/scoped_inflector.rb +35 -0
  154. data/lib/rails_pulse/statistics.rb +27 -0
  155. data/lib/rails_pulse/subscribers/exception_subscriber.rb +2 -2
  156. data/lib/rails_pulse/subscribers/operation_subscriber.rb +15 -29
  157. data/lib/rails_pulse/tasks/status_reporter.rb +26 -0
  158. data/lib/rails_pulse/tracker.rb +75 -14
  159. data/lib/rails_pulse/version.rb +1 -1
  160. data/lib/rails_pulse.rb +0 -4
  161. data/public/rails-pulse-assets/rails-pulse.css +1 -1
  162. data/public/rails-pulse-assets/rails-pulse.js +13 -13
  163. metadata +96 -24
  164. data/app/controllers/concerns/page_timings.rb +0 -10
  165. data/app/controllers/concerns/response_range_concern.rb +0 -45
  166. data/app/controllers/concerns/time_range_concern.rb +0 -152
  167. data/app/controllers/concerns/zoom_range_concern.rb +0 -86
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b54f69a078537dd6dabc4074623c3deaf4b0123f4c229b57c706583f104d1b62
4
- data.tar.gz: 2cd90650cc8ceb9cf15dac48c4d4695113c73a52fdb4661153fa5c45cbf3b378
3
+ metadata.gz: 3a494a9f4ff4c1f388ed3f87f6911cfae02b9efb329673f1b697b8af5c589f4a
4
+ data.tar.gz: bd9fc701eccf980bca1c8aa1269dc5549fa3882f905c8691cb9fb70cc842b399
5
5
  SHA512:
6
- metadata.gz: 125303510a989e6de226b732d14fd76d001b5641feb4f93bd8ae5daf07ec73b2328c022097c36be6b4153cd270a9344bf8d4f14e1364843b96fdabd07beb0aab
7
- data.tar.gz: d262a5d599007772f1cccc087b6166fd3a151f93f3cdf990bcd37b3ffed6dc6df3aa785a959ae0ae92ac56df545d9a299aed1ec41e3f3d605adb868ce9bb22d0
6
+ metadata.gz: 642d5edc8eb02e9b7d4b5e393995ebd84782f2068eef36e075820ce9fed47db44ec3ce8bce4fec076e9a4d2b055a95b7c390309ab83dc51a3c13f2c4babc8608
7
+ data.tar.gz: adcc2d2f7ce7176cbadd781abe14cc13df14002a4ba5e48f178c2853074118a0996d0c09e752077b65cf5c4eab77387f49a5ffff5b45b08422a460fab7a4f95a
data/CHANGELOG.md CHANGED
@@ -5,7 +5,39 @@ All notable changes to Rails Pulse will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
- ## 0.4.2 - 2025-10-01
8
+ ## [Unreleased]
9
+
10
+ ## [0.5.0.pre.1] - 2026-10-03
11
+
12
+ ### Added
13
+
14
+ - **JSON API, `rails-pulse` CLI and MCP server.** A read-only API under `/rails_pulse/api/v1`, a `rails-pulse` executable and an MCP server (`rails-pulse mcp`; add `gem "mcp"` to your Gemfile) give scripts, CI and coding agents what the dashboard shows: routes, requests, queries, jobs, job runs, exceptions and deployments. (#309)
15
+ - **Coverage reporting for agents.** `rails-pulse coverage show` and the `rails_pulse_coverage` tool report what has been recorded, how recently, any collection gaps, and which application and environment answered, so missing data is not mistaken for an all-clear. (#309)
16
+ - **Fixed windows and drilldown.** Every windowed command and tool takes ISO 8601 `since`/`until` and reports the window it measured, and `rails_pulse_queries` takes an endpoint's `route_id` to show the SQL inside it with the file and line each query came from. (#309)
17
+ - **Agent instructions.** `rails-pulse install claude` installs a Claude Code skill, and `rails-pulse install agents` writes a Rails Pulse section into `AGENTS.md`, alongside a project's own instructions with `--append`. (#309)
18
+ - `rails rails_pulse:status` reports whether the API and deployment tokens are set. (#309)
19
+ - **Dropped requests are now visible.** Each background writer records a heartbeat once a minute (queue depth and requests dropped since the last one), pruned after a day. The dashboard's health bar gains a Tracking badge, shown only when a writer is backlogged or dropping requests; the Storage page lists every live writer with its queue and drops; and `rails rails_pulse:status` reports the totals and exits 1 when anything was dropped in the last hour. (#281)
20
+ - **A `rails_pulse_events` table** for what Rails Pulse notices rather than measures, starting with the writer heartbeats above. `config.event_retention_period` (default 90 days) prunes it. Run `rails generate rails_pulse:upgrade` and migrate; tracking pauses until the table exists.
21
+ - CI now exercises the separate-database upgrade path (`bin/test_separate_database_upgrade`, SQLite and PostgreSQL), and the migration regression suite gains a 0.3.2 baseline. (#284)
22
+ - **Charts say which time zone they are in.** Every chart carries a zone badge (hover for the full zone name and the exact window shown), chart tooltips end with the zone, and the custom date range picker states the zone its inputs are read in. (#303)
23
+
24
+ ### Changed
25
+
26
+ - **Daily, weekly and monthly summaries are built from shorter periods instead of raw rows.** The summary job's memory no longer grows with the length of the period or with raised retention caps, and longer periods stay complete after raw data is pruned; their P50/P95/P99 are now traffic-weighted from the hours or days below them, as the dashboard already shows for multi-period ranges. A period is summarized only once it ends, so the backfill task no longer writes a partial row for the current day. (#279)
27
+ - **`config.deployment_api_token` is now `config.deployment_token`.** The old name still works and still only records deployments; the JSON API reads a separate `config.api_token`, so a token given to a coding agent cannot record a release. (#309)
28
+ - **Recording a deployment over HTTP requires `config.deployment_token`.** The deployments endpoint no longer accepts a dashboard login in place of the token, which let any page a signed-in admin visited record a release; set the token for CI, or use the `rails_pulse:record_deployment` task, which needs none. (#309)
29
+ - **All timestamps display in the app's `config.time_zone`.** Chart axes and tooltips are formatted in that zone rather than the browser's, so a daily point no longer lands on the wrong calendar day for viewers in another zone. Request, job, exception and operation timestamps also use it instead of the server's OS zone; on a host whose server runs in UTC with a different `config.time_zone`, those pages now show the configured zone. (#303)
30
+ - **Dashboard health bar badges omit zero counts.** "26 healthy · 0 slow · 0 critical" now reads "26 healthy"; the Storage badge is shown only under warning or critical pressure.
31
+ - **Dropped the `request_store` runtime dependency.** Per-request tracking state now goes through `RailsPulse::Current`, built on Rails' own `ActiveSupport::CurrentAttributes`. No configuration or behavior change; a host that read `RequestStore.store[:rails_pulse_request_id]` directly (undocumented, but reachable) needs to switch to `RailsPulse::Current.rails_pulse_request_id`. (#277)
32
+ - **Requires Ruby 3.2+ and Rails 7.2+.** The gemspec advertised Ruby 3.1 and Rails 7.1 but CI never ran them; the floors now match what is tested, and the untested Rails 7.1 and Ruby 3.1 code paths are gone. (#270)
33
+
34
+ ### Fixed
35
+
36
+ - Weekly and monthly summaries written by `SummaryJob` now include the period's last day. (#279)
37
+ - Backfill date arguments are read in the app's time zone, so backfilled summaries land on the same period boundaries the scheduled job writes instead of UTC midnights. (#279)
38
+ - **The exception-group row cap no longer counts preserved and ignored groups.** Once those exempt groups approached the cap, every cleanup run deleted the oldest deletable groups without ever getting under it. The cap now applies to deletable groups only. (#285)
39
+
40
+ ## [0.4.2] - 2026-10-01
9
41
 
10
42
  ### Changed
11
43
 
data/README.md CHANGED
@@ -8,7 +8,7 @@
8
8
 
9
9
  ![Gem Version](https://img.shields.io/gem/v/rails_pulse)
10
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)
11
+ ![Ruby Version](https://img.shields.io/badge/Ruby-3.2%2B-red)
12
12
  ![License](https://img.shields.io/badge/License-MIT-green)
13
13
 
14
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.
@@ -27,6 +27,7 @@ Rails Pulse is a Rails engine. It hooks into the instrumentation Rails already e
27
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
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
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
+ - **Your coding agent can read it.** A read-only JSON API, a `rails-pulse` CLI and an MCP server give Claude Code, Codex, Cursor or a CI script the same data: the slowest endpoints since the last deploy, the queries behind them, the jobs that failed. Ask the agent why checkout got slow and it can go and look.
30
31
 
31
32
  <table>
32
33
  <tr>
@@ -89,7 +90,7 @@ production:
89
90
 
90
91
  Open `http://localhost:3000/rails_pulse`. That's the whole setup.
91
92
 
92
- Requirements: Ruby 3.1+, Rails 7.2+ (tested on 7.2, 8.0 and 8.1), SQLite, PostgreSQL or MySQL.
93
+ Requirements: Ruby 3.2+, Rails 7.2+ (tested on 7.2, 8.0 and 8.1), SQLite, PostgreSQL or MySQL.
93
94
 
94
95
  Full install guide, including a separate database and plain cron: [railspulse.com/documentation/installation](https://railspulse.com/documentation/installation)
95
96
 
@@ -109,7 +110,17 @@ With nothing configured it falls back to HTTP Basic against `RAILS_PULSE_USERNAM
109
110
 
110
111
  **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)
111
112
 
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.
113
+ **Mark your deploys.** `rails rails_pulse:record_deployment[sha]` from a release script, or `POST /rails_pulse/deployments` with the API token from CI, and every chart draws a line at that moment.
114
+
115
+ **Brief your agent.** Set `config.api_token`, then on your machine:
116
+
117
+ ```bash
118
+ rails-pulse configure # URL and token, saved to ~/.rails-pulse
119
+ rails-pulse routes list --since 2026-06-01T00:00:00Z
120
+ rails-pulse install claude # Claude Code skill: when and how to use the tools
121
+ ```
122
+
123
+ Add `gem "mcp"` to your Gemfile (a development group is enough), register `rails-pulse mcp` as an MCP server, and the agent gets ten read-only tools: routes, slow requests, errors, exception groups and their backtraces, one endpoint in depth, expensive and N+1 queries, job health, deployments, and what has actually been recorded. Nothing the agent can call changes production. [Agent tooling](https://railspulse.com/documentation/mcp)
113
124
 
114
125
  **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)
115
126
 
@@ -126,7 +137,7 @@ Upgrading from 0.3.x to 0.4? **Back up first**, run `rails rails_pulse:migrate_r
126
137
 
127
138
  ## Contributing
128
139
 
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.
140
+ 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. Building on top of Rails Pulse (a plugin, scripting the CLI)? [`docs/api.md`](docs/api.md) states what's public and stable across minor releases.
130
141
 
131
142
  ```bash
132
143
  git config core.hooksPath .githooks # once, after cloning
@@ -85,6 +85,17 @@
85
85
  border-left: none;
86
86
  }
87
87
 
88
+ /* Aggregation-zone badge beside a chart title or tab strip. Its title
89
+ attribute carries the full zone name and the resolved window, hence the
90
+ help cursor. */
91
+ .zone-badge {
92
+ font-size: var(--text-xs);
93
+ font-weight: var(--font-normal);
94
+ color: var(--color-text-subtle);
95
+ white-space: nowrap;
96
+ cursor: help;
97
+ }
98
+
88
99
  .panel-tab {
89
100
  display: inline-flex;
90
101
  align-items: center;
@@ -1,3 +1,9 @@
1
+ /* @container rules match descendants of the container, not the container
2
+ itself, so the containment context has to live on the parent. */
3
+ :has(> .descriptive-list) {
4
+ container-type: inline-size;
5
+ }
6
+
1
7
  .descriptive-list {
2
8
  display: grid;
3
9
  grid-template-columns: 200px 1fr;
@@ -6,9 +12,21 @@
6
12
 
7
13
  .descriptive-list dt, .descriptive-list dd {
8
14
  font-size: var(--text-sm);
15
+ min-width: 0;
9
16
  }
10
17
 
11
18
  .descriptive-list dd {
12
19
  overflow-wrap: break-word;
13
- min-width: 0;
20
+ }
21
+
22
+ /* Below this the fixed 200px label column crowds out the value column */
23
+ @container (max-width: 340px) {
24
+ .descriptive-list {
25
+ grid-template-columns: 1fr;
26
+ gap: 0.125rem 0.5rem;
27
+ }
28
+
29
+ .descriptive-list dd {
30
+ margin-block-end: 0.5rem;
31
+ }
14
32
  }
@@ -23,7 +23,7 @@
23
23
  flex: 1;
24
24
  }
25
25
 
26
- /* Responsive layout for screens smaller than 768px */
26
+ /* Single column below 768px */
27
27
  @media (max-width: 768px) {
28
28
  .row {
29
29
  display: flex;
@@ -34,35 +34,21 @@
34
34
  }
35
35
 
36
36
  .row > * {
37
- flex: 0 0 calc(50% - 0.25rem);
37
+ flex: 0 0 100%;
38
38
  min-width: 0;
39
39
  height: auto;
40
40
  }
41
41
 
42
42
  .row > .grid-item {
43
43
  height: auto;
44
+ min-height: auto;
44
45
  }
45
46
 
46
47
  .row > .grid-item > * {
47
48
  flex: none;
48
49
  }
49
50
 
50
- /* Tables and chart panels should stack in single column on tablet */
51
- .row:has(.table-container) > *,
52
- .row:has(.chart-container) > * {
53
- flex: 0 0 100%;
54
- }
55
-
56
- /* Single column for very small screens */
57
51
  @media (max-width: 480px) {
58
- .row > * {
59
- flex: 0 0 100%;
60
- }
61
-
62
- .row > .grid-item {
63
- min-height: auto;
64
- }
65
-
66
52
  /* Make metric cards more compact on mobile */
67
53
  .row > .grid-item .card {
68
54
  padding: var(--size-3);
@@ -2,7 +2,6 @@
2
2
  #
3
3
  # Core concern for controllers that display both charts and tables (routes, requests, queries, jobs).
4
4
  # Orchestrates time range setup, chart generation, table queries, and zoom/filter handling.
5
- # Includes TimeRangeConcern, ResponseRangeConcern, and ZoomRangeConcern for filter management.
6
5
  #
7
6
  # Controllers including this concern must implement:
8
7
  # - chart_model, table_model, chart_definitions, default_table_sort, build_table_results
@@ -13,9 +12,7 @@ module ChartTableConcern
13
12
  VALID_PERIOD_TYPES = %w[hour day].freeze
14
13
 
15
14
  included do
16
- include TimeRangeConcern
17
- include ResponseRangeConcern
18
- include ZoomRangeConcern
15
+ include RansackParamsConcern
19
16
  include DeploymentMarkersConcern
20
17
 
21
18
  before_action :setup_page_timings
@@ -24,32 +21,16 @@ module ChartTableConcern
24
21
 
25
22
  private
26
23
 
24
+ # Hook: override to change the default time range (e.g. :last_7_days).
25
+ def default_time_range_key = :last_24_hours
26
+
27
27
  def setup_page_timings
28
- start_time, end_time, selected_time_range, time_diff_hours = setup_time_range
29
- start_duration, selected_response_range = setup_duration_range(duration_range_type)
30
- zoom_start, zoom_end, table_start_time, table_end_time =
31
- setup_zoom_range(start_time, end_time)
32
-
33
- @page_timings = PageTimings.new(
34
- start_time: start_time, end_time: end_time,
35
- table_start_time: table_start_time, table_end_time: table_end_time,
36
- zoom_start: zoom_start, zoom_end: zoom_end,
37
- time_diff_hours: time_diff_hours, start_duration: start_duration,
38
- selected_time_range: selected_time_range, selected_response_range: selected_response_range
28
+ @time_range = RailsPulse::TimeRange.resolve(
29
+ params: params,
30
+ session: session,
31
+ default_key: default_time_range_key,
32
+ duration_range_type: duration_range_type
39
33
  )
40
-
41
- # Keep individual @vars for backward compat with views, helpers, and other
42
- # concerns (DeploymentMarkersConcern, MetricCardConcern, ChartHelper).
43
- @start_time = start_time
44
- @end_time = end_time
45
- @time_diff_hours = time_diff_hours
46
- @start_duration = start_duration
47
- @selected_time_range = selected_time_range
48
- @selected_response_range = selected_response_range
49
- @zoom_start = zoom_start
50
- @zoom_end = zoom_end
51
- @table_start_time = table_start_time
52
- @table_end_time = table_end_time
53
34
  end
54
35
 
55
36
  def setup_chart_and_table_data
@@ -78,9 +59,8 @@ module ChartTableConcern
78
59
  common_options = {
79
60
  ransack_query: chart_ransack_query,
80
61
  period_type: period_type,
81
- start_time: @page_timings.start_time,
82
- end_time: @page_timings.end_time,
83
- start_duration: @page_timings.start_duration,
62
+ window: @time_range&.window,
63
+ start_duration: @time_range&.start_duration,
84
64
  disabled_tags: session_disabled_tags,
85
65
  show_non_tagged: session[:show_non_tagged] != false,
86
66
  **chart_options
@@ -110,22 +90,7 @@ module ChartTableConcern
110
90
  end
111
91
 
112
92
  def period_type
113
- time_diff = @page_timings&.time_diff_hours
114
-
115
- type = if time_diff.nil?
116
- "day" # Default to day for "recent" mode or when time_diff isn't set
117
- elsif time_diff <= 25
118
- "hour"
119
- else
120
- "day"
121
- end
122
-
123
- # Validate period type to prevent SQL injection via string interpolation
124
- unless VALID_PERIOD_TYPES.include?(type)
125
- raise ArgumentError, "Invalid period_type: #{type}. Must be one of: #{VALID_PERIOD_TYPES.join(", ")}"
126
- end
127
-
128
- type
93
+ @time_range&.period_type || "day"
129
94
  end
130
95
 
131
96
  def meaningful_chart_data?
@@ -181,15 +146,13 @@ module ChartTableConcern
181
146
 
182
147
  # Builds ransack parameters for chart queries
183
148
  # Common pattern: time range + optional duration filter + resource scope
184
- # Handles "recent" mode where @page_timings.start_time/@end_time may be nil
185
149
  def build_chart_ransack_params(ransack_params)
186
150
  base_params = ransack_params.except(:s, *chart_filter_exclusions)
187
151
 
188
- # Add time filters if we have time boundaries (not in "recent" mode)
189
- if @page_timings&.start_time && @page_timings&.end_time
152
+ if @time_range&.window
190
153
  base_params.merge!(
191
- period_start_gteq: Time.at(@page_timings.start_time),
192
- period_start_lt: Time.at(@page_timings.end_time)
154
+ period_start_gteq: @time_range.window.start_time,
155
+ period_start_lt: @time_range.window.end_time
193
156
  )
194
157
  end
195
158
 
@@ -197,8 +160,8 @@ module ChartTableConcern
197
160
  base_params.merge!(summarizable_type_eq: summarizable_type) if summarizable_type
198
161
 
199
162
  # Only add duration filter if we have a meaningful threshold
200
- if @page_timings&.start_duration && @page_timings.start_duration > 0
201
- base_params[:avg_duration_gteq] = @page_timings.start_duration
163
+ if @time_range&.start_duration && @time_range.start_duration > 0
164
+ base_params[:avg_duration_gteq] = @time_range.start_duration
202
165
  end
203
166
 
204
167
  # Scope to specific resource on show pages
@@ -220,41 +183,37 @@ module ChartTableConcern
220
183
  end
221
184
 
222
185
  # Builds table params for show pages (individual records like Request, JobRun)
223
- # Handles "recent" mode where time boundaries may be nil
224
186
  def build_show_table_ransack_params(ransack_params)
225
187
  params = ransack_params.dup
226
188
 
227
- # Add time filters if we have time boundaries (not in "recent" mode)
228
- if @page_timings&.table_start_time && @page_timings&.table_end_time
189
+ if @time_range&.table_window
229
190
  params.merge!(
230
- occurred_at_gteq: Time.at(@page_timings.table_start_time),
231
- occurred_at_lt: Time.at(@page_timings.table_end_time)
191
+ occurred_at_gteq: @time_range.table_window.start_time,
192
+ occurred_at_lt: @time_range.table_window.end_time
232
193
  )
233
194
  end
234
195
 
235
196
  params.merge!(show_resource_filter)
236
- if @page_timings&.start_duration && @page_timings.start_duration > 0
237
- params[:duration_gteq] = @page_timings.start_duration
197
+ if @time_range&.start_duration && @time_range.start_duration > 0
198
+ params[:duration_gteq] = @time_range.start_duration
238
199
  end
239
200
  params
240
201
  end
241
202
 
242
203
  # Builds table params for index pages (summary records)
243
- # Handles "recent" mode where time boundaries may be nil
244
204
  def build_index_table_ransack_params(ransack_params)
245
205
  params = ransack_params.dup
246
206
 
247
- # Add time filters if we have time boundaries (not in "recent" mode)
248
- if @page_timings&.table_start_time && @page_timings&.table_end_time
207
+ if @time_range&.table_window
249
208
  params.merge!(
250
- period_start_gteq: Time.at(@page_timings.table_start_time),
251
- period_start_lt: Time.at(@page_timings.table_end_time)
209
+ period_start_gteq: @time_range.table_window.start_time,
210
+ period_start_lt: @time_range.table_window.end_time
252
211
  )
253
212
  end
254
213
 
255
214
  params.merge!(summarizable_type_eq: summarizable_type) if summarizable_type
256
- if @page_timings&.start_duration && @page_timings.start_duration > 0
257
- params[:avg_duration_gteq] = @page_timings.start_duration
215
+ if @time_range&.start_duration && @time_range.start_duration > 0
216
+ params[:avg_duration_gteq] = @time_range.start_duration
258
217
  end
259
218
  params
260
219
  end
@@ -285,7 +244,7 @@ module ChartTableConcern
285
244
  raise NotImplementedError, "#{self.class} must implement #chart_definitions"
286
245
  end
287
246
 
288
- # Returns options passed to chart classes (e.g., { route: @route })
247
+ # Returns options passed to chart classes (e.g., { subject: @route })
289
248
  def chart_options
290
249
  {}
291
250
  end
@@ -1,7 +1,9 @@
1
1
  module DeploymentMarkersConcern
2
2
  def populate_deployment_markers
3
+ return unless @time_range&.window
4
+
3
5
  @deployment_markers = RailsPulse::Deployment
4
- .for_range(Time.zone.at(@start_time), Time.zone.at(@end_time))
6
+ .for_range(@time_range.window.start_time, @time_range.window.end_time)
5
7
  .map(&:to_chart_marker)
6
8
  end
7
9
  end
@@ -33,14 +33,13 @@ module MetricCardConcern
33
33
  end
34
34
  end
35
35
 
36
- # Common parameters passed to all metric card classes
36
+ # Common parameters passed to all metric card classes. Deliberately does
37
+ # NOT pass window: — these cards show a "trailing period" figure, not the
38
+ # exact selected range (ChartTableConcern's charts get the exact window;
39
+ # see Cards::Base#window_days).
37
40
  def metric_card_params
38
- # For "recent" mode with no time filtering, use a default period of 7 days
39
- period_days = if @start_time.nil? || @end_time.nil?
40
- 7
41
- else
42
- ((@end_time - @start_time) / 1.day).round
43
- end
41
+ window = @time_range&.window
42
+ period_days = window ? ((window.end_time - window.start_time) / 1.day).round : 7
44
43
 
45
44
  {
46
45
  resource_key => current_resource,
@@ -0,0 +1,130 @@
1
+ module RailsPulse
2
+ module Api
3
+ module V1
4
+ # Read-only JSON API for the rails-pulse CLI, the MCP server, CI scripts
5
+ # and coding agents. Authenticated by config.api_token alone: the
6
+ # dashboard session is never consulted, and with no token configured
7
+ # every request is refused.
8
+ class BaseController < RailsPulse::ApplicationController
9
+ skip_before_action :authenticate_rails_pulse_user!
10
+ skip_before_action :set_show_non_tagged_default
11
+ skip_before_action :set_onboarding_state
12
+ skip_before_action :load_deployment_markers
13
+
14
+ # Prepended so it runs ahead of the inherited require_current_schema!:
15
+ # the schema report lists missing tables and columns and must not be
16
+ # served to anonymous callers.
17
+ prepend_before_action :authenticate_api_token!
18
+
19
+ # After authentication, so an anonymous caller learns nothing from
20
+ # which of its parameters were refused.
21
+ before_action :validate_params!
22
+
23
+ # Every action is a GET read from the query string. Rails would
24
+ # otherwise copy a JSON request's parameters into a hash named after
25
+ # the controller (`route`, `job`), which collides with the filters of
26
+ # the same name whenever a client sends Content-Type: application/json.
27
+ wrap_parameters false
28
+
29
+ # Parameters every endpoint reads as one string. A repeated or nested
30
+ # one (`search[]=x`) is refused rather than reaching a String method.
31
+ SCALAR_PARAMS = %i[limit offset min_requests occurrences since until search route status sort job].freeze
32
+ INTEGER_PARAMS = %i[limit offset min_requests occurrences].freeze
33
+
34
+ # Past any real table, and inside a 64-bit integer on every adapter.
35
+ MAX_INTEGER = 1_000_000_000
36
+
37
+ # A date, optionally a time, optionally a zone. Time.parse would also
38
+ # take "10" as the 10th of this month, which is not a window anyone
39
+ # asked for.
40
+ ISO8601 = /\A\d{4}-\d{2}-\d{2}(?:[T ]\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?)?(?:Z|[+-]\d{2}:?\d{2})?\z/i
41
+
42
+ private
43
+
44
+ def authenticate_api_token!
45
+ token = RailsPulse.configuration.api_token.to_s
46
+ provided = request.headers["X-Rails-Pulse-Token"].to_s
47
+ return if token.present? && ActiveSupport::SecurityUtils.secure_compare(provided, token)
48
+
49
+ render json: { error: "Unauthorized" }, status: :unauthorized
50
+ end
51
+
52
+ # The schema report picks its format from the request; API callers
53
+ # rarely send an Accept header, so answer JSON regardless.
54
+ def require_current_schema!
55
+ request.format = :json
56
+ super
57
+ end
58
+
59
+ def validate_params!
60
+ SCALAR_PARAMS.each do |name|
61
+ value = params[name]
62
+ next if value.nil? || value.is_a?(String)
63
+
64
+ return render_bad_request("'#{name}' must be a single value")
65
+ end
66
+
67
+ INTEGER_PARAMS.each do |name|
68
+ value = params[name]
69
+ next if value.blank? || value.match?(/\A\d+\z/)
70
+
71
+ return render_bad_request("'#{name}' must be a whole number")
72
+ end
73
+
74
+ @since_time = parse_time_param(:since)
75
+ return if performed?
76
+ @until_time = parse_time_param(:until)
77
+ return if performed?
78
+
79
+ if @since_time && @until_time && @until_time <= @since_time
80
+ render_bad_request("'until' must be later than 'since'")
81
+ end
82
+ end
83
+
84
+ def render_bad_request(message)
85
+ render json: { error: message }, status: :bad_request
86
+ end
87
+
88
+ def limit
89
+ integer_param(:limit, 25, 1..500)
90
+ end
91
+
92
+ def offset
93
+ integer_param(:offset, 0, 0..MAX_INTEGER)
94
+ end
95
+
96
+ def integer_param(name, default, range)
97
+ params[name].blank? ? default : params[name].to_i.clamp(range)
98
+ end
99
+
100
+ # A time with no zone is read as UTC, so a window means the same thing
101
+ # wherever the caller and the server are.
102
+ def parse_time_param(name)
103
+ value = params[name]
104
+ return if value.blank?
105
+
106
+ string = value.strip
107
+ raise ArgumentError unless string.match?(ISO8601)
108
+
109
+ ActiveSupport::TimeZone["UTC"].parse(string)
110
+ rescue ArgumentError
111
+ render_bad_request(
112
+ "Invalid time format for '#{name}'. Use ISO 8601, such as 2026-09-24T12:00:00Z; " \
113
+ "a time with no zone is read as UTC."
114
+ )
115
+ end
116
+
117
+ # Parsed and checked by validate_params! before the action runs.
118
+ def time_range
119
+ [ @since_time, @until_time ]
120
+ end
121
+
122
+ def paginated(collection)
123
+ total = collection.count
124
+ data = collection.limit(limit).offset(offset)
125
+ [ data, { total: total, limit: limit, offset: offset } ]
126
+ end
127
+ end
128
+ end
129
+ end
130
+ end
@@ -0,0 +1,27 @@
1
+ module RailsPulse
2
+ module Api
3
+ module V1
4
+ # Which installation answered, so a caller can tell one deployment's
5
+ # numbers from another's before it starts reading them.
6
+ class CapabilitiesController < BaseController
7
+ def show
8
+ render json: {
9
+ rails_pulse_version: RailsPulse::VERSION,
10
+ environment: Rails.env.to_s,
11
+ # Names the installation a response came from, so two profiles
12
+ # pointed at staging and production cannot be confused.
13
+ application: application_name
14
+ }
15
+ end
16
+
17
+ private
18
+
19
+ def application_name
20
+ Rails.application.class.module_parent_name
21
+ rescue StandardError
22
+ nil
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end