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
@@ -0,0 +1,98 @@
1
+ module RailsPulse
2
+ module Api
3
+ module V1
4
+ class RoutesController < BaseController
5
+ SORT_COLUMNS = %w[request_count avg_duration error_count].freeze
6
+
7
+ def index
8
+ parsed_range = time_range
9
+ return unless parsed_range
10
+ since_start, until_end = parsed_range
11
+
12
+ sort = params[:sort].presence
13
+ if sort && !SORT_COLUMNS.include?(sort)
14
+ return render json: { error: "Invalid sort. Valid values: #{SORT_COLUMNS.join(', ')}" }, status: :bad_request
15
+ end
16
+
17
+ scope = RailsPulse::Route.all
18
+ if params[:search].present?
19
+ term = RailsPulse::LikePattern.containing(params[:search].downcase)
20
+ scope = scope.where(
21
+ "LOWER(path) LIKE :term #{RailsPulse::LikePattern::CLAUSE} " \
22
+ "OR LOWER(controller_action) LIKE :term #{RailsPulse::LikePattern::CLAUSE}",
23
+ term: term
24
+ )
25
+ end
26
+
27
+ if since_start || until_end || sort
28
+ since_start ||= 24.hours.ago if until_end.nil?
29
+ render_with_stats(scope, since_start..until_end, sort || "request_count")
30
+ else
31
+ data, meta = paginated(scope.order(:path, :id))
32
+ render json: { data: data.map { |route| RouteSerializer.serialize(route) }, meta: meta }
33
+ end
34
+ end
35
+
36
+ private
37
+
38
+ def render_with_stats(scope, range, sort)
39
+ base = RailsPulse::Request.where(occurred_at: range)
40
+ base = base.where(route_id: scope.select(:id)) if params[:search].present?
41
+
42
+ # HAVING rather than a filter over the fetched page: a route busy
43
+ # enough to qualify can rank below the limit, and dropping it after
44
+ # the fact returns nothing while an answer exists.
45
+ grouped = base.group(:route_id)
46
+ grouped = grouped.having("COUNT(*) >= ?", min_requests) if min_requests > 1
47
+
48
+ # Grouping yields one row per route, so counting the rows counts the
49
+ # routes that qualify.
50
+ routes_with_traffic = base.distinct.count(:route_id)
51
+ total = min_requests > 1 ? grouped.count.size : routes_with_traffic
52
+
53
+ # CASE WHEN on the boolean column itself is valid on SQLite, PostgreSQL
54
+ # and MySQL alike, so no adapter-specific literal is interpolated.
55
+ rows = grouped
56
+ .select(
57
+ "route_id, COUNT(*) AS request_count, AVG(duration) AS avg_duration, " \
58
+ "SUM(CASE WHEN is_error THEN 1 ELSE 0 END) AS error_count"
59
+ )
60
+ # route_id breaks ties so offset pages neither repeat nor skip rows.
61
+ .order(Arel.sql("#{sort} DESC, route_id ASC"))
62
+ .limit(limit)
63
+ .offset(offset)
64
+ .to_a
65
+
66
+ routes = RailsPulse::Route.where(id: rows.map(&:route_id)).index_by(&:id)
67
+ data = rows.filter_map do |row|
68
+ route = routes[row.route_id]
69
+ RouteSerializer.serialize(route, stats: stats_for(row)) if route
70
+ end
71
+
72
+ meta = { total: total, limit: limit, offset: offset }
73
+ # Lets a caller tell "nothing ran in this window" from "nothing ran
74
+ # often enough", which are different next steps, without asking again
75
+ # at a lower threshold.
76
+ if min_requests > 1
77
+ meta[:min_requests] = min_requests
78
+ meta[:routes_with_traffic] = routes_with_traffic
79
+ end
80
+
81
+ render json: { data: data, meta: meta }
82
+ end
83
+
84
+ def min_requests
85
+ integer_param(:min_requests, 1, 1..MAX_INTEGER)
86
+ end
87
+
88
+ def stats_for(row)
89
+ {
90
+ request_count: row.request_count.to_i,
91
+ avg_duration_ms: row.avg_duration.to_f.round(1),
92
+ error_count: row.error_count.to_i
93
+ }
94
+ end
95
+ end
96
+ end
97
+ end
98
+ end
@@ -102,7 +102,7 @@ module RailsPulse
102
102
  private
103
103
 
104
104
  # The original string if it is short enough to live in the session and
105
- # Time.parse accepts it (TimeRangeConcern parses it back the same way);
105
+ # Time.parse accepts it (RailsPulse::TimeRange parses it back the same way);
106
106
  # nil otherwise.
107
107
  def valid_time_param(value)
108
108
  value = value.to_s.strip
@@ -1,22 +1,16 @@
1
1
  module RailsPulse
2
2
  class DashboardController < ApplicationController
3
- include TimeRangeConcern
4
3
  include DeploymentMarkersConcern
5
4
 
6
5
  def index
7
- # Use TimeRangeConcern to get time range (supports time range selector + all other filters)
8
- @start_time, @end_time, @selected_time_range, @time_diff = setup_time_range
6
+ @time_range = RailsPulse::TimeRange.resolve(
7
+ params: params, session: session, default_key: default_time_range_key
8
+ )
9
9
  populate_deployment_markers
10
10
 
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
16
-
17
- # Determine period type based on time range
18
- # If 24 hours or less, use hourly summaries, otherwise use daily
19
- @period_type = @period <= 1 ? "hour" : "day"
11
+ window = @time_range.window
12
+ period = window.days
13
+ period_type = @time_range.period_type
20
14
 
21
15
  # Get tag filter values from session
22
16
  disabled_tags = session_disabled_tags
@@ -24,13 +18,13 @@ module RailsPulse
24
18
 
25
19
  card_and_chart_options = {
26
20
  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
21
+ period: period, period_type: period_type,
22
+ window: window
29
23
  }
30
24
 
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
25
+ @percentile_response_times_metric_card = RailsPulse::Routes::Cards::PercentileResponseTimes.new(**card_and_chart_options).to_metric_card
26
+ @request_count_totals_metric_card = RailsPulse::Routes::Cards::RequestCountTotals.new(**card_and_chart_options).to_metric_card
27
+ @error_rates_metric_card = RailsPulse::Routes::Cards::ErrorRates.new(**card_and_chart_options).to_metric_card
34
28
  @job_failure_rate_metric_card = RailsPulse::Jobs::Cards::FailureRate.new(**card_and_chart_options).to_metric_card if RailsPulse.configuration.track_jobs
35
29
 
36
30
  # Generate chart data for inline rendering
@@ -38,19 +32,25 @@ module RailsPulse
38
32
  @throughput_and_errors_chart_data = RailsPulse::Dashboard::Charts::ThroughputAndErrors.new(**card_and_chart_options).to_chart_data
39
33
 
40
34
  # Needs Attention panel
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
35
+ @needs_attention = RailsPulse::Dashboard::NeedsAttention.new(disabled_tags: disabled_tags, show_non_tagged: show_non_tagged, period: period, window: window).to_attention_data
42
36
 
43
37
  # System Health bar
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
38
+ @health_summary = RailsPulse::Dashboard::HealthSummary.new(disabled_tags: disabled_tags, show_non_tagged: show_non_tagged, period: period, window: window).to_health_data
45
39
 
46
40
  @storage_status = RailsPulse::Dashboard::StorageStatus.new(cached_sizes: true)
47
41
 
48
42
  # Deployments panel — scoped to the same window as the chart markers so
49
43
  # the panel and the markers drawn on the charts always agree.
50
- deployments_in_range = RailsPulse::Deployment.for_range(Time.zone.at(@start_time), Time.zone.at(@end_time))
44
+ deployments_in_range = RailsPulse::Deployment.for_range(window.start_time, window.end_time)
51
45
  @deployment_count = deployments_in_range.count
52
46
  @recent_deployments = deployments_in_range.recent.limit(4)
53
47
  @last_deployment = RailsPulse::Deployment.recent.first
54
48
  end
49
+
50
+ private
51
+
52
+ def default_time_range_key
53
+ :last_24_hours
54
+ end
55
55
  end
56
56
  end
@@ -6,7 +6,10 @@ module RailsPulse
6
6
  # widen the hole the 0.4.0 audit closed.
7
7
  skip_before_action :authenticate_rails_pulse_user!, only: %i[create finish]
8
8
  skip_before_action :verify_authenticity_token, only: %i[create finish]
9
- before_action :authenticate_deployment_request!, only: %i[create finish]
9
+ # Prepended so the token is checked before the inherited callbacks run:
10
+ # the schema check would otherwise show an anonymous caller the list of
11
+ # missing tables, and the dashboard callbacks would query on its behalf.
12
+ prepend_before_action :authenticate_deployment_request!, only: %i[create finish]
10
13
 
11
14
  def index
12
15
  @ransack_query = Deployment.ransack(ransack_params)
@@ -53,19 +56,26 @@ module RailsPulse
53
56
 
54
57
  private
55
58
 
59
+ # Only config.deployment_token authorizes a write here. config.api_token
60
+ # reads the JSON API and is the credential given to CLI callers and coding
61
+ # agents, so accepting it would let any of them record a release.
62
+ #
63
+ # The dashboard login is never accepted instead. CSRF protection is off for
64
+ # these actions so CI can post, and a browser attaches a saved login (HTTP
65
+ # Basic in particular) to a request another site triggers, so a login
66
+ # fallback would let any page an admin visits record a release. A header
67
+ # token cannot be sent cross-site. The rake tasks run inside the app and
68
+ # need no token.
56
69
  def authenticate_deployment_request!
57
- token = RailsPulse.configuration.deployment_api_token
58
- if token.present?
59
- provided = request.headers["X-Rails-Pulse-Token"].to_s
60
- unless ActiveSupport::SecurityUtils.secure_compare(provided, token)
61
- render json: { error: "Unauthorized" }, status: :unauthorized
62
- end
63
- elsif RailsPulse.configuration.authentication_enabled
64
- authenticate_rails_pulse_user!
65
- else
66
- # No token configured and authentication disabled — fail closed.
67
- # Without a token there is no way to verify the caller.
68
- render json: { error: "Unauthorized — set deployment_api_token or enable authentication" }, status: :unauthorized
70
+ token = RailsPulse.configuration.deployment_token.to_s
71
+ if token.empty?
72
+ render json: { error: "Unauthorized — set config.deployment_token to record deployments over HTTP" }, status: :unauthorized
73
+ return
74
+ end
75
+
76
+ provided = request.headers["X-Rails-Pulse-Token"].to_s
77
+ unless ActiveSupport::SecurityUtils.secure_compare(provided, token)
78
+ render json: { error: "Unauthorized" }, status: :unauthorized
69
79
  end
70
80
  end
71
81
 
@@ -1,6 +1,6 @@
1
1
  module RailsPulse
2
2
  class ExceptionsController < ApplicationController
3
- include TimeRangeConcern
3
+ include RansackParamsConcern
4
4
  include TagFilterConcern
5
5
  include DeploymentMarkersConcern
6
6
 
@@ -38,22 +38,20 @@ module RailsPulse
38
38
  # without them the page can say what is failing but not whether it is
39
39
  # getting worse, which is the question users actually arrive with.
40
40
  def setup_frequency_view
41
- # Timestamps, not Times, to match every other index controller — the
42
- # shared concerns and helpers that read @start_time expect integers.
43
- @start_time, @end_time, @selected_time_range, @time_diff_hours = setup_time_range
41
+ @time_range = RailsPulse::TimeRange.resolve(
42
+ params: params, session: session, default_key: default_time_range_key
43
+ )
44
44
 
45
- @period_type = @time_diff_hours <= 25 ? "hour" : "day"
46
- period_days = window_days_for(@selected_time_range)
45
+ period_days = window_days_for(@time_range.selected_time_range, @time_range.window)
47
46
 
48
47
  populate_deployment_markers
49
48
 
50
49
  @occurrence_volume_chart = Exceptions::Charts::OccurrenceVolume.new(
51
- start_time: Time.zone.at(@start_time),
52
- end_time: Time.zone.at(@end_time),
53
- period_type: @period_type
50
+ window: @time_range.window,
51
+ period_type: @time_range.period_type
54
52
  ).to_chart_data
55
53
 
56
- card_params = { period: period_days, period_type: @period_type }
54
+ card_params = { period: period_days, period_type: @time_range.period_type }
57
55
 
58
56
  @total_occurrences_metric_card = Exceptions::Cards::TotalOccurrences.new(**card_params).to_metric_card
59
57
  @exception_rate_metric_card = Exceptions::Cards::ExceptionRate.new(**card_params).to_metric_card
@@ -67,13 +65,13 @@ module RailsPulse
67
65
  # The card copy ("compared to previous N days") has to name the range the
68
66
  # user picked. Measuring the span instead reads one day long, because the
69
67
  # range runs from the start of the first day to the end of the last.
70
- def window_days_for(selected_range)
68
+ def window_days_for(selected_range, window)
71
69
  case selected_range.to_s
72
70
  when "last_24_hours" then 1
73
71
  when "last_7_days" then 7
74
72
  when "last_14_days" then 14
75
73
  when "last_30_days" then 30
76
- else [ ((@end_time - @start_time) / 1.day.to_i).round, 1 ].max
74
+ else window.days
77
75
  end
78
76
  end
79
77
 
@@ -57,7 +57,7 @@ module RailsPulse
57
57
 
58
58
  # Pass the job to chart classes on show pages
59
59
  def chart_options
60
- show_action? ? { job: @job } : {}
60
+ show_action? ? { subject: @job } : {}
61
61
  end
62
62
 
63
63
  # Jobs use polymorphic summaries, so we need to filter by type
@@ -102,7 +102,6 @@ module RailsPulse
102
102
  Jobs::Tables::Index.new(
103
103
  ransack_query: @ransack_query,
104
104
  period_type: period_type,
105
- start_time: @start_time,
106
105
  params: params,
107
106
  disabled_tags: session_disabled_tags,
108
107
  show_non_tagged: session[:show_non_tagged] != false,
@@ -30,8 +30,7 @@ module RailsPulse
30
30
 
31
31
  # Database load chart doesn't use ransack_query, so generate it separately
32
32
  @database_load_chart_data = Queries::Charts::DatabaseLoad.new(
33
- start_time: @start_time,
34
- end_time: @end_time,
33
+ window: @time_range&.window,
35
34
  period_type: period_type,
36
35
  disabled_tags: session_disabled_tags,
37
36
  show_non_tagged: session[:show_non_tagged] != false
@@ -55,10 +54,13 @@ module RailsPulse
55
54
 
56
55
  # Database load card only shows on index page and doesn't accept query param
57
56
  if current_resource.nil?
57
+ window = @time_range&.window
58
+ period_days = window ? ((window.end_time - window.start_time) / 1.day).round : 7
59
+
58
60
  @database_load_metric_card = Queries::Cards::DatabaseLoad.new(
59
61
  disabled_tags: session_disabled_tags,
60
62
  show_non_tagged: session[:show_non_tagged] != false,
61
- period: ((@end_time - @start_time) / 1.day).round,
63
+ period: period_days,
62
64
  period_type: period_type.to_s
63
65
  ).to_metric_card
64
66
  end
@@ -87,7 +89,7 @@ module RailsPulse
87
89
 
88
90
  # Pass the query to chart classes on show pages
89
91
  def chart_options
90
- show_action? ? { query: @query } : {}
92
+ show_action? ? { subject: @query } : {}
91
93
  end
92
94
 
93
95
  # Queries use polymorphic summaries, so we need to filter by type
@@ -127,7 +129,6 @@ module RailsPulse
127
129
  Queries::Tables::Index.new(
128
130
  ransack_query: @ransack_query,
129
131
  period_type: period_type,
130
- start_time: @start_time,
131
132
  params: params,
132
133
  disabled_tags: session_disabled_tags,
133
134
  show_non_tagged: session[:show_non_tagged] != false
@@ -40,23 +40,23 @@ module RailsPulse
40
40
 
41
41
  # Override: Requests aggregate all request data (summarizable_id: 0)
42
42
  # rather than scoping to a specific resource
43
- # Also handles "recent" mode where @start_time may be nil
44
43
  def build_chart_ransack_params(ransack_params)
45
44
  base_params = ransack_params.except(:s).merge(
46
45
  summarizable_type_eq: "RailsPulse::Request",
47
46
  summarizable_id_eq: 0
48
47
  )
49
48
 
50
- # Add time filters if we have time boundaries (not in "recent" mode)
51
- if @start_time && @end_time
49
+ if @time_range&.window
52
50
  base_params.merge!(
53
- period_start_gteq: Time.at(@start_time),
54
- period_start_lt: Time.at(@end_time)
51
+ period_start_gteq: @time_range.window.start_time,
52
+ period_start_lt: @time_range.window.end_time
55
53
  )
56
54
  end
57
55
 
58
56
  # Only add duration filter if we have a meaningful threshold
59
- base_params[:avg_duration_gteq] = @start_duration if @start_duration && @start_duration > 0
57
+ if @time_range&.start_duration && @time_range.start_duration > 0
58
+ base_params[:avg_duration_gteq] = @time_range.start_duration
59
+ end
60
60
  base_params
61
61
  end
62
62
 
@@ -67,25 +67,26 @@ module RailsPulse
67
67
  # Handle time mode - check if recent mode is selected
68
68
  time_mode = params[:period_start_range] || "recent"
69
69
 
70
- if time_mode != "recent" && @table_start_time && @table_end_time
70
+ if time_mode != "recent" && @time_range&.table_window
71
71
  # Custom mode - apply time filters
72
72
  params.merge!(
73
- occurred_at_gteq: Time.at(@table_start_time),
74
- occurred_at_lt: Time.at(@table_end_time)
73
+ occurred_at_gteq: @time_range.table_window.start_time,
74
+ occurred_at_lt: @time_range.table_window.end_time
75
75
  )
76
76
  end
77
77
  # else: Recent mode - no time filters, just rely on sort + pagination
78
78
 
79
- # Duration filter - convert symbol to numeric threshold or use @start_duration
79
+ start_duration = @time_range&.start_duration
80
+ # Duration filter - convert symbol to numeric threshold or use start_duration
80
81
  if params[:duration_gteq].present?
81
82
  # If it's a symbol like :slow, convert it to the numeric threshold
82
83
  if params[:duration_gteq].to_s.in?(%w[slow very_slow critical])
83
- params[:duration_gteq] = @start_duration
84
+ params[:duration_gteq] = start_duration
84
85
  end
85
86
  # else: it's already a number, keep it as is
86
- elsif @start_duration && @start_duration > 0
87
- # No duration_gteq param, use @start_duration from concern
88
- params[:duration_gteq] = @start_duration
87
+ elsif start_duration && start_duration > 0
88
+ # No duration_gteq param, use start_duration from @time_range
89
+ params[:duration_gteq] = start_duration
89
90
  end
90
91
 
91
92
  # Min response size filter - input is in KB, convert to bytes for the column
@@ -52,7 +52,7 @@ module RailsPulse
52
52
 
53
53
  # Pass the route to chart classes on show pages
54
54
  def chart_options
55
- show_action? ? { route: @route } : {}
55
+ show_action? ? { subject: @route } : {}
56
56
  end
57
57
 
58
58
  def show_resource_filter
@@ -103,7 +103,6 @@ module RailsPulse
103
103
  Routes::Tables::Index.new(
104
104
  ransack_query: @ransack_query,
105
105
  period_type: period_type,
106
- start_time: @start_time,
107
106
  params: params,
108
107
  disabled_tags: session_disabled_tags,
109
108
  show_non_tagged: session[:show_non_tagged] != false
@@ -135,17 +134,14 @@ module RailsPulse
135
134
  # day/week/month periods) so the table isn't just blank for old ranges.
136
135
  def setup_archived_summary_data
137
136
  cutoff = retention_cutoff
138
- window_start = @page_timings&.table_start_time
137
+ table_window = @time_range&.table_window
139
138
 
140
- scope = if cutoff && window_start && Time.at(window_start) < cutoff
139
+ scope = if cutoff && table_window && table_window.start_time < cutoff
141
140
  scope = Summary.for_routes
142
141
  .where(summarizable_id: @route.id)
143
142
  .where(period_type: period_type)
144
143
  .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
144
+ .where("period_start < ?", table_window.end_time)
149
145
 
150
146
  scope.order(period_start: :desc)
151
147
  else
@@ -28,6 +28,21 @@ module RailsPulse
28
28
  end
29
29
  end
30
30
 
31
+ # Tooltip text for the chart-panel zone badge: the exact resolved interval
32
+ # (when a controller has resolved one) plus the aggregation zone every
33
+ # chart — hourly or daily — is consistently labeled in.
34
+ def time_range_zone_tooltip
35
+ zone = RailsPulse::TimeRange.aggregation_zone_label
36
+ lines = []
37
+
38
+ if (window = @time_range&.window)
39
+ lines << "Showing #{window.start_time.strftime('%b %-d, %Y %l:%M %p')} – #{window.end_time.strftime('%b %-d, %Y %l:%M %p')} (#{zone})"
40
+ end
41
+
42
+ lines << "All times are shown in #{zone}."
43
+ lines.join("\n")
44
+ end
45
+
31
46
  def page_url(page_number)
32
47
  url_for(request.query_parameters.merge(page: page_number))
33
48
  end
@@ -22,7 +22,14 @@ module RailsPulse
22
22
  rails_pulse__chart_type_value: type,
23
23
  rails_pulse__chart_data_value: data.to_json,
24
24
  rails_pulse__chart_options_value: chart_options.to_json,
25
- rails_pulse__chart_theme_value: theme
25
+ rails_pulse__chart_theme_value: theme,
26
+ # Lets the chart format daily labels/tooltips in the zone summaries
27
+ # were actually bucketed in, instead of the browser's zone (which can
28
+ # land a daily boundary on the wrong calendar day — see #303). The
29
+ # short label keeps sparkline tooltips readable; the full zone name
30
+ # is in the badge's title next to each chart.
31
+ rails_pulse__chart_timezone_value: RailsPulse::TimeRange.aggregation_zone_iana,
32
+ rails_pulse__chart_timezone_label_value: RailsPulse::TimeRange.aggregation_zone_short_label
26
33
  }
27
34
 
28
35
  content_tag(:div, "",
@@ -34,7 +41,7 @@ module RailsPulse
34
41
 
35
42
  # Base chart options shared across all chart types
36
43
  def base_chart_options(units: nil, zoom: false)
37
- hourly = @period_type == "hour" || (@time_diff_hours && @time_diff_hours <= 25)
44
+ hourly = @time_range&.period_type == "hour"
38
45
  x_formatter = hourly ? "time" : "timestamp_to_date"
39
46
  tooltip_formatter = units == "%" ? "tooltip_with_timestamp_rate" : "tooltip_with_timestamp"
40
47
  {
@@ -2,20 +2,19 @@ module RailsPulse
2
2
  module FormattingHelper
3
3
  def human_readable_occurred_at(occurred_at)
4
4
  return "" unless occurred_at.present?
5
- time = occurred_at.is_a?(String) ? Time.parse(occurred_at) : occurred_at
6
- # Convert to local system timezone (same as charts use)
7
- time.getlocal.strftime("%b %d, %Y %l:%M %p")
5
+ # Time.zone, not the server's OS zone — the same zone charts label as
6
+ # the aggregation zone, so this and the charts always agree.
7
+ time = occurred_at.is_a?(String) ? Time.zone.parse(occurred_at) : occurred_at
8
+ return "" if time.nil?
9
+ time.strftime("%b %d, %Y %l:%M %p")
8
10
  end
9
11
 
10
12
  def time_ago_in_words(time)
11
13
  return "Unknown" if time.blank?
12
14
 
13
- # Convert to Time object if it's a string
14
- time = Time.parse(time.to_s) if time.is_a?(String)
15
- # Convert to local system timezone for consistent calculation
16
- time = time.getlocal
15
+ time = Time.zone.parse(time.to_s) if time.is_a?(String)
17
16
 
18
- seconds_ago = [ Time.now - time, 0 ].max
17
+ seconds_ago = [ Time.current - time, 0 ].max
19
18
 
20
19
  case seconds_ago
21
20
  when 0..59
@@ -37,10 +36,10 @@ module RailsPulse
37
36
  def human_readable_summary_period(summary)
38
37
  return "" unless summary&.period_start&.present? && summary&.period_end&.present?
39
38
 
40
- # Convert UTC times to local system timezone to match chart display
41
- start_time = summary.period_start.getlocal
42
- end_time = summary.period_end.getlocal
43
-
39
+ # Already Time.zone-aware from ActiveRecord — the same aggregation
40
+ # zone charts label, so no conversion needed.
41
+ start_time = summary.period_start
42
+ end_time = summary.period_end
44
43
 
45
44
  case summary.period_type
46
45
  when "hour"