rails_pulse 0.4.0.pre.4 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +64 -87
  3. data/README.md +83 -111
  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 +9 -5
  7. data/app/controllers/rails_pulse/dashboard_controller.rb +20 -11
  8. data/app/controllers/rails_pulse/routes_controller.rb +46 -0
  9. data/app/helpers/rails_pulse/application_helper.rb +4 -0
  10. data/app/javascript/rails_pulse/controllers/chart_controller.js +27 -0
  11. data/app/models/rails_pulse/cards/base.rb +61 -25
  12. data/app/models/rails_pulse/dashboard/charts/response_time_percentiles.rb +38 -15
  13. data/app/models/rails_pulse/dashboard/charts/throughput_and_errors.rb +36 -13
  14. data/app/models/rails_pulse/dashboard/concerns/time_range_helper.rb +3 -0
  15. data/app/models/rails_pulse/dashboard/health_summary.rb +3 -1
  16. data/app/models/rails_pulse/dashboard/needs_attention.rb +3 -1
  17. data/app/models/rails_pulse/dashboard/storage_status.rb +38 -68
  18. data/app/models/rails_pulse/exceptions/cards/exception_rate.rb +1 -5
  19. data/app/models/rails_pulse/exceptions/cards/open_groups.rb +4 -4
  20. data/app/models/rails_pulse/exceptions/cards/total_occurrences.rb +3 -4
  21. data/app/models/rails_pulse/jobs/cards/failure_rate.rb +7 -20
  22. data/app/models/rails_pulse/jobs/cards/p95_duration.rb +5 -8
  23. data/app/models/rails_pulse/jobs/cards/total_runs.rb +4 -4
  24. data/app/models/rails_pulse/queries/cards/average_query_times.rb +6 -9
  25. data/app/models/rails_pulse/queries/cards/database_load.rb +7 -10
  26. data/app/models/rails_pulse/queries/cards/execution_rate.rb +4 -6
  27. data/app/models/rails_pulse/queries/cards/percentile_query_times.rb +5 -8
  28. data/app/models/rails_pulse/route.rb +7 -1
  29. data/app/models/rails_pulse/routes/cards/error_rates.rb +5 -11
  30. data/app/models/rails_pulse/routes/cards/percentile_response_times.rb +3 -1
  31. data/app/models/rails_pulse/routes/cards/request_count_totals.rb +4 -6
  32. data/app/models/rails_pulse/summary.rb +13 -18
  33. data/app/models/rails_pulse/time_range_preference.rb +28 -0
  34. data/app/models/rails_pulse/time_window.rb +61 -0
  35. data/app/services/rails_pulse/summary_service.rb +112 -162
  36. data/app/services/rails_pulse/tag_filter_service.rb +10 -1
  37. data/app/views/layouts/rails_pulse/_time_range_selector.html.erb +2 -2
  38. data/app/views/rails_pulse/dashboard/index.html.erb +6 -3
  39. data/app/views/rails_pulse/routes/_archived_summary_table.html.erb +46 -0
  40. data/app/views/rails_pulse/routes/_archived_table_pagination.html.erb +28 -0
  41. data/app/views/rails_pulse/routes/show.html.erb +14 -2
  42. data/lib/generators/rails_pulse/install_generator.rb +5 -2
  43. data/lib/generators/rails_pulse/templates/rails_pulse.rb +7 -2
  44. data/lib/rails_pulse/cleanup_service.rb +7 -7
  45. data/lib/rails_pulse/configuration.rb +9 -1
  46. data/lib/rails_pulse/engine.rb +14 -42
  47. data/lib/rails_pulse/middleware/request_collector.rb +2 -17
  48. data/lib/rails_pulse/standalone.rb +12 -0
  49. data/lib/rails_pulse/subscribers/operation_subscriber.rb +45 -3
  50. data/lib/rails_pulse/tracker.rb +241 -25
  51. data/lib/rails_pulse/version.rb +1 -1
  52. data/lib/rails_pulse.rb +3 -16
  53. data/lib/rails_pulse_server.ru +37 -23
  54. data/public/rails-pulse-assets/rails-pulse.js +1 -1
  55. metadata +7 -9
  56. data/app/assets/images/rails_pulse/dashboard.png +0 -0
  57. data/app/assets/images/rails_pulse/query-show.png +0 -0
  58. data/app/assets/images/rails_pulse/request-show.png +0 -0
  59. data/app/assets/images/rails_pulse/request.png +0 -0
  60. data/app/assets/images/rails_pulse/route-show.png +0 -0
  61. data/lib/rails_pulse/extensions/active_record.rb +0 -124
@@ -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.
@@ -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
@@ -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) {
@@ -5,12 +5,20 @@ module RailsPulse
5
5
  class Base
6
6
  private
7
7
 
8
+ # nil unless the caller passed both start_time and end_time, so the
9
+ # "trailing @period days/hours" fallback below still applies by default.
10
+ def time_window
11
+ return @time_window if defined?(@time_window)
12
+
13
+ @time_window = RailsPulse::TimeWindow.build(@start_time, @end_time)
14
+ end
15
+
8
16
  def now
9
- @now ||= Time.current
17
+ @now ||= time_window&.end_time || Time.current
10
18
  end
11
19
 
12
20
  def window_days
13
- @period || 7
21
+ time_window&.days || @period || 7
14
22
  end
15
23
 
16
24
  def period_type_hours?
@@ -19,7 +27,9 @@ module RailsPulse
19
27
 
20
28
  # Enhanced time period helpers (support hour and day)
21
29
  def previous_window_start
22
- if period_type_hours?
30
+ if time_window
31
+ time_window.previous(period_type_hours? ? "hour" : "day").start_time
32
+ elsif period_type_hours?
23
33
  (now - (window_days * 48).hours).beginning_of_hour
24
34
  else
25
35
  (now - (window_days * 2).days).beginning_of_day
@@ -27,7 +37,9 @@ module RailsPulse
27
37
  end
28
38
 
29
39
  def current_window_start
30
- if period_type_hours?
40
+ if time_window
41
+ time_window.start_time
42
+ elsif period_type_hours?
31
43
  (now - (window_days * 24).hours).beginning_of_hour
32
44
  else
33
45
  (now - window_days.days).beginning_of_day
@@ -82,26 +94,15 @@ module RailsPulse
82
94
  end
83
95
 
84
96
  def build_hourly_sparkline(grouped_values)
85
- start_time = sparkline_start
86
- end_time = now.beginning_of_hour
87
-
88
- {}.tap do |hash|
89
- current_time = start_time
90
- while current_time <= end_time
91
- # Use timestamp in milliseconds for JS compatibility
92
- hash[current_time.to_i * 1000] = { value: grouped_values[current_time] || 0 }
93
- current_time += 1.hour
94
- end
97
+ sparkline_hours.each_with_object({}) do |current_time, hash|
98
+ # Use timestamp in milliseconds for JS compatibility
99
+ hash[current_time.to_i * 1000] = { value: grouped_values[current_time] || 0 }
95
100
  end
96
101
  end
97
102
 
98
103
  def build_daily_sparkline(grouped_values)
99
- start_date = sparkline_start.to_date
100
- end_date = now.to_date
101
-
102
- (start_date..end_date).each_with_object({}) do |day, hash|
103
- label = day.strftime("%b %-d")
104
- hash[label] = { value: grouped_values[day] || 0 }
104
+ sparkline_dates.each_with_object({}) do |day, hash|
105
+ hash[day.strftime("%b %-d")] = { value: grouped_values[day] || 0 }
105
106
  end
106
107
  end
107
108
 
@@ -112,6 +113,27 @@ module RailsPulse
112
113
  current_window_start
113
114
  end
114
115
 
116
+ # Every calendar date in the sparkline window. Via TimeWindow, so a
117
+ # range not starting on a day boundary skips the leading partial day.
118
+ def sparkline_dates
119
+ time_window&.dates || (sparkline_start.to_date..now.to_date).to_a
120
+ end
121
+
122
+ # Same skip-partial semantics as sparkline_dates, for hours.
123
+ def sparkline_hours
124
+ time_window&.hour_starts || begin
125
+ start_time = sparkline_start.beginning_of_hour
126
+ end_time = now.beginning_of_hour
127
+ hours = []
128
+ current_time = start_time
129
+ while current_time <= end_time
130
+ hours << current_time
131
+ current_time += 1.hour
132
+ end
133
+ hours
134
+ end
135
+ end
136
+
115
137
  # Build sparkline query with tag filters and optional subject filter
116
138
  def build_sparkline_query(summarizable_type)
117
139
  sparkline_query = RailsPulse::Summary
@@ -127,15 +149,29 @@ module RailsPulse
127
149
 
128
150
  # Group sparkline query by period type (hour or day)
129
151
  def group_sparkline_by_period(sparkline_query, sum_field)
130
- if period_type_hours?
131
- sparkline_query.group_by_hour(:period_start).sum(sum_field)
132
- else
133
- sparkline_query.group_by_date(:period_start).sum(sum_field)
152
+ bucket_by_period(sparkline_query) { |relation| relation.sum(sum_field) }
153
+ end
154
+
155
+ # Aggregates `relation` per calendar day of Time.zone, or per hour when
156
+ # the card's period type is "hour". The block receives the relation
157
+ # grouped by `column` and does the arithmetic in SQL; only the
158
+ # re-bucketing of keys happens here. It is done in Ruby because SQL
159
+ # DATE() / DATE_TRUNC() operate on the stored UTC value, which puts a
160
+ # Melbourne midnight on the previous calendar date and a Kolkata hour
161
+ # thirty minutes off, so the sparkline lookups (keyed by Time.zone
162
+ # dates and hour starts) would miss.
163
+ def bucket_by_period(relation, column: :period_start)
164
+ yield(relation.group(column)).each_with_object({}) do |(time, value), buckets|
165
+ next if time.nil? || value.nil?
166
+
167
+ local = time.in_time_zone
168
+ key = period_type_hours? ? local.beginning_of_hour : local.to_date
169
+ buckets[key] = (buckets[key] || 0) + value
134
170
  end
135
171
  end
136
172
 
137
173
  def period_date_range
138
- start_date = current_window_start.to_date
174
+ start_date = sparkline_dates.first || current_window_start.to_date
139
175
  end_date = now.to_date
140
176
  if start_date.year == end_date.year
141
177
  "#{start_date.strftime("%b %-d")} – #{end_date.strftime("%b %-d")}"
@@ -2,18 +2,21 @@ module RailsPulse
2
2
  module Dashboard
3
3
  module Charts
4
4
  class ResponseTimePercentiles
5
- def initialize(disabled_tags: [], show_non_tagged: true, period: 7, period_type: "day")
5
+ def initialize(disabled_tags: [], show_non_tagged: true, period: 7, period_type: "day", start_time: nil, end_time: nil)
6
6
  @disabled_tags = disabled_tags
7
7
  @show_non_tagged = show_non_tagged
8
8
  @period = period
9
9
  @period_type = period_type
10
+ @start_time = start_time
11
+ @end_time = end_time
10
12
  end
11
13
 
12
14
  def to_chart_data
13
15
  if @period_type == "hour"
14
- # Create a range of all hours in the selected period
15
- start_time = (@period * 24).hours.ago.beginning_of_hour
16
- end_time = Time.current.beginning_of_hour
16
+ # Exact selected hours when explicit, else "trailing @period days".
17
+ hours = time_window&.hour_starts || default_hour_range
18
+ start_time = hours.first
19
+ end_time = hours.last
17
20
 
18
21
  # Get the actual data from Summary records (routes)
19
22
  summaries = RailsPulse::Summary
@@ -56,13 +59,7 @@ module RailsPulse
56
59
  }
57
60
  end
58
61
 
59
- # Build hourly time range
60
- time_range = []
61
- current_time = start_time
62
- while current_time <= end_time
63
- time_range << current_time
64
- current_time += 1.hour
65
- end
62
+ time_range = hours
66
63
 
67
64
  # Build series data as [timestamp_ms, value] pairs for ECharts time axis
68
65
  timestamps_ms = time_range.map { |t| t.to_i * 1000 }
@@ -70,10 +67,10 @@ module RailsPulse
70
67
  p95_data = time_range.map { |time| [ time.to_i * 1000, final_data[time]&.[](:p95) ] }
71
68
  p99_data = time_range.map { |time| [ time.to_i * 1000, final_data[time]&.[](:p99) ] }
72
69
  else
73
- # Daily grouping (existing logic)
74
- start_date = @period.days.ago.beginning_of_day.to_date
75
- end_date = Time.current.to_date
76
- date_range = (start_date..end_date)
70
+ # Exact selected days when explicit, else "trailing @period days".
71
+ date_range = time_window&.dates || default_day_range
72
+ start_date = date_range.first
73
+ end_date = date_range.last
77
74
 
78
75
  summaries = RailsPulse::Summary
79
76
  .with_tag_filters(@disabled_tags, @show_non_tagged)
@@ -169,6 +166,32 @@ module RailsPulse
169
166
  { labels: labels, series: series }
170
167
  end
171
168
  end
169
+
170
+ private
171
+
172
+ # nil unless both start_time and end_time were given, so the default
173
+ # "recent" view keeps using the trailing-@period-days fallback below.
174
+ def time_window
175
+ @time_window ||= RailsPulse::TimeWindow.build(@start_time, @end_time)
176
+ end
177
+
178
+ def default_hour_range
179
+ start_time = (@period * 24).hours.ago.beginning_of_hour
180
+ end_time = Time.current.beginning_of_hour
181
+ hours = []
182
+ current_time = start_time
183
+ while current_time <= end_time
184
+ hours << current_time
185
+ current_time += 1.hour
186
+ end
187
+ hours
188
+ end
189
+
190
+ def default_day_range
191
+ start_date = @period.days.ago.beginning_of_day.to_date
192
+ end_date = Time.current.to_date
193
+ (start_date..end_date).to_a
194
+ end
172
195
  end
173
196
  end
174
197
  end
@@ -2,17 +2,20 @@ module RailsPulse
2
2
  module Dashboard
3
3
  module Charts
4
4
  class ThroughputAndErrors
5
- def initialize(disabled_tags: [], show_non_tagged: true, period: 7, period_type: "day")
5
+ def initialize(disabled_tags: [], show_non_tagged: true, period: 7, period_type: "day", start_time: nil, end_time: nil)
6
6
  @disabled_tags = disabled_tags
7
7
  @show_non_tagged = show_non_tagged
8
8
  @period = period
9
9
  @period_type = period_type
10
+ @start_time = start_time
11
+ @end_time = end_time
10
12
  end
11
13
 
12
14
  def to_chart_data
13
15
  if @period_type == "hour"
14
- start_time = (@period * 24).hours.ago.beginning_of_hour
15
- end_time = Time.current.beginning_of_hour
16
+ hours = time_window&.hour_starts || default_hour_range
17
+ start_time = hours.first
18
+ end_time = hours.last
16
19
 
17
20
  summaries = RailsPulse::Summary
18
21
  .with_tag_filters(@disabled_tags, @show_non_tagged)
@@ -40,13 +43,7 @@ module RailsPulse
40
43
  }
41
44
  end
42
45
 
43
- # Build hourly time range
44
- time_range = []
45
- current_time = start_time
46
- while current_time <= end_time
47
- time_range << current_time
48
- current_time += 1.hour
49
- end
46
+ time_range = hours
50
47
 
51
48
  series = [
52
49
  {
@@ -70,9 +67,9 @@ module RailsPulse
70
67
 
71
68
  return { series: series }
72
69
  else
73
- start_date = @period.days.ago.beginning_of_day.to_date
74
- end_date = Time.current.to_date
75
- date_range = (start_date..end_date)
70
+ date_range = time_window&.dates || default_day_range
71
+ start_date = date_range.first
72
+ end_date = date_range.last
76
73
 
77
74
  summaries = RailsPulse::Summary
78
75
  .with_tag_filters(@disabled_tags, @show_non_tagged)
@@ -125,6 +122,32 @@ module RailsPulse
125
122
 
126
123
  { labels: labels, series: series }
127
124
  end
125
+
126
+ private
127
+
128
+ # nil unless both start_time and end_time were given, so the default
129
+ # "recent" view keeps using the trailing-@period-days fallback below.
130
+ def time_window
131
+ @time_window ||= RailsPulse::TimeWindow.build(@start_time, @end_time)
132
+ end
133
+
134
+ def default_hour_range
135
+ start_time = (@period * 24).hours.ago.beginning_of_hour
136
+ end_time = Time.current.beginning_of_hour
137
+ hours = []
138
+ current_time = start_time
139
+ while current_time <= end_time
140
+ hours << current_time
141
+ current_time += 1.hour
142
+ end
143
+ hours
144
+ end
145
+
146
+ def default_day_range
147
+ start_date = @period.days.ago.beginning_of_day.to_date
148
+ end_date = Time.current.to_date
149
+ (start_date..end_date).to_a
150
+ end
128
151
  end
129
152
  end
130
153
  end
@@ -5,6 +5,9 @@ module RailsPulse
5
5
  private
6
6
 
7
7
  def period_range
8
+ window = RailsPulse::TimeWindow.build(@start_time, @end_time)
9
+ return [ window.start_time, window.end_time ] if window
10
+
8
11
  [ @period.days.ago.beginning_of_day, Time.current ]
9
12
  end
10
13
  end
@@ -4,10 +4,12 @@ module RailsPulse
4
4
  include Concerns::ThresholdConstants
5
5
  include Concerns::TimeRangeHelper
6
6
 
7
- def initialize(disabled_tags: [], show_non_tagged: true, period: 7)
7
+ def initialize(disabled_tags: [], show_non_tagged: true, period: 7, start_time: nil, end_time: nil)
8
8
  @disabled_tags = disabled_tags
9
9
  @show_non_tagged = show_non_tagged
10
10
  @period = period
11
+ @start_time = start_time
12
+ @end_time = end_time
11
13
  @route_thresholds = RailsPulse.configuration.route_thresholds
12
14
  @query_thresholds = RailsPulse.configuration.query_thresholds
13
15
  @job_thresholds = RailsPulse.configuration.job_thresholds
@@ -4,10 +4,12 @@ module RailsPulse
4
4
  include Concerns::ThresholdConstants
5
5
  include Concerns::TimeRangeHelper
6
6
 
7
- def initialize(disabled_tags: [], show_non_tagged: true, period: 7)
7
+ def initialize(disabled_tags: [], show_non_tagged: true, period: 7, start_time: nil, end_time: nil)
8
8
  @disabled_tags = disabled_tags
9
9
  @show_non_tagged = show_non_tagged
10
10
  @period = period
11
+ @start_time = start_time
12
+ @end_time = end_time
11
13
  @route_thresholds = RailsPulse.configuration.route_thresholds
12
14
  @query_thresholds = RailsPulse.configuration.query_thresholds
13
15
  @job_thresholds = RailsPulse.configuration.job_thresholds
@@ -74,51 +74,35 @@ module RailsPulse
74
74
  }
75
75
  ].freeze
76
76
 
77
- # One-off screenshot fixture. Set SCREENSHOT to false (or delete this block) when done.
78
- SCREENSHOT = !Rails.env.test?
79
- SCREENSHOT_STATE = :warning # :healthy, :warning, :critical
80
- SCREENSHOT_TABLES = {
81
- healthy: {
82
- rails_pulse_operations: { count: 42_180, limit: 100_000, recent: 3_140, bytes: 100_663_296, days_old: 14 },
83
- rails_pulse_requests: { count: 18_420, limit: 50_000, recent: 1_280, bytes: 42_991_616, days_old: 14 },
84
- rails_pulse_job_runs: { count: 12_104, limit: 50_000, recent: 890, bytes: 29_360_128, days_old: 14 },
85
- rails_pulse_exception_occurrences: { count: 1_842, limit: 50_000, recent: 22, bytes: 11_534_336, days_old: 14 },
86
- rails_pulse_summaries: { count: 1_128, limit: nil, recent: 48, bytes: 4_403_200, days_old: 14 },
87
- rails_pulse_queries: { count: 486, limit: 10_000, recent: 6, bytes: 2_202_010, days_old: 62 },
88
- rails_pulse_routes: { count: 214, limit: 1_000, recent: 1, bytes: 421_888, days_old: 62 },
89
- rails_pulse_jobs: { count: 38, limit: 1_000, recent: 0, bytes: 98_304, days_old: 62 },
90
- rails_pulse_exception_groups: { count: 64, limit: 10_000, recent: 2, bytes: 184_320, days_old: 62 },
91
- rails_pulse_deployments: { count: 12, limit: nil, recent: 0, bytes: 49_152, days_old: 48 }
92
- },
93
- warning: {
94
- rails_pulse_operations: { count: 78_410, limit: 100_000, recent: 6_820, bytes: 176_160_768, days_old: 14 },
95
- rails_pulse_requests: { count: 36_210, limit: 50_000, recent: 2_410, bytes: 81_788_928, days_old: 14 },
96
- rails_pulse_job_runs: { count: 31_002, limit: 50_000, recent: 1_940, bytes: 54_525_952, days_old: 14 },
97
- rails_pulse_exception_occurrences: { count: 4_118, limit: 50_000, recent: 40, bytes: 18_874_368, days_old: 14 },
98
- rails_pulse_summaries: { count: 1_240, limit: nil, recent: 48, bytes: 4_612_096, days_old: 14 },
99
- rails_pulse_queries: { count: 812, limit: 10_000, recent: 12, bytes: 3_250_176, days_old: 90 },
100
- rails_pulse_routes: { count: 268, limit: 1_000, recent: 2, bytes: 491_520, days_old: 90 },
101
- rails_pulse_jobs: { count: 41, limit: 1_000, recent: 0, bytes: 106_496, days_old: 90 },
102
- rails_pulse_exception_groups: { count: 91, limit: 10_000, recent: 3, bytes: 225_280, days_old: 90 },
103
- rails_pulse_deployments: { count: 18, limit: nil, recent: 1, bytes: 65_536, days_old: 90 }
104
- },
105
- critical: {
106
- rails_pulse_operations: { count: 104_280, limit: 100_000, recent: 8_110, bytes: 224_395_264, days_old: 18 },
107
- rails_pulse_requests: { count: 48_902, limit: 50_000, recent: 3_040, bytes: 106_954_752, days_old: 18 },
108
- rails_pulse_job_runs: { count: 46_118, limit: 50_000, recent: 2_280, bytes: 84_934_656, days_old: 18 },
109
- rails_pulse_exception_occurrences: { count: 8_640, limit: 50_000, recent: 90, bytes: 25_165_824, days_old: 18 },
110
- rails_pulse_summaries: { count: 1_410, limit: nil, recent: 24, bytes: 5_033_984, days_old: 18 },
111
- rails_pulse_queries: { count: 1_204, limit: 10_000, recent: 18, bytes: 4_300_800, days_old: 120 },
112
- rails_pulse_routes: { count: 312, limit: 1_000, recent: 4, bytes: 552_960, days_old: 120 },
113
- rails_pulse_jobs: { count: 44, limit: 1_000, recent: 0, bytes: 114_688, days_old: 120 },
114
- rails_pulse_exception_groups: { count: 128, limit: 10_000, recent: 5, bytes: 286_720, days_old: 120 },
115
- rails_pulse_deployments: { count: 22, limit: nil, recent: 1, bytes: 73_728, days_old: 120 }
116
- }
117
- }.freeze
77
+ # Table sizes come from dbstat / pg_total_relation_size /
78
+ # information_schema, one statement per table. The dashboard only shows
79
+ # a headline total, so it reads them through a short-lived per-process
80
+ # cache; the Storage page itself always measures afresh.
81
+ SIZE_CACHE_TTL = 5.minutes
82
+ @size_cache = {}
83
+ @size_cache_mutex = Mutex.new
84
+
85
+ class << self
86
+ def cached_table_bytes(table_name)
87
+ @size_cache_mutex.synchronize do
88
+ entry = @size_cache[table_name]
89
+ return entry[:bytes] if entry && entry[:measured_at] > SIZE_CACHE_TTL.ago
90
+
91
+ bytes = yield
92
+ @size_cache[table_name] = { bytes: bytes, measured_at: Time.current }
93
+ bytes
94
+ end
95
+ end
118
96
 
119
- def initialize
97
+ def reset_size_cache!
98
+ @size_cache_mutex.synchronize { @size_cache.clear }
99
+ end
100
+ end
101
+
102
+ def initialize(cached_sizes: false)
120
103
  @config = RailsPulse.configuration
121
104
  @pressure = StoragePressure.new
105
+ @cached_sizes = cached_sizes
122
106
  end
123
107
 
124
108
  def tables
@@ -178,33 +162,13 @@ module RailsPulse
178
162
  RailsPulse::ApplicationRecord.connection
179
163
  end
180
164
 
181
- def screenshot_row(table_name)
182
- return unless SCREENSHOT
183
-
184
- row = SCREENSHOT_TABLES.dig(SCREENSHOT_STATE, table_name)
185
- return unless row
186
-
187
- oldest_at = row[:days_old].days.ago
188
- {
189
- count: row[:count],
190
- limit: row[:limit],
191
- recent_count: row[:recent],
192
- bytes: row[:bytes],
193
- oldest_at: oldest_at,
194
- newest_at: 8.minutes.ago
195
- }
196
- end
197
-
198
165
  def build_table(definition)
199
- screenshot = screenshot_row(definition[:name])
200
- unless screenshot
201
- model = definition[:model].safe_constantize
202
- return unless model
203
- return unless connection.table_exists?(definition[:name])
204
- end
166
+ model = definition[:model].safe_constantize
167
+ return unless model
168
+ return unless connection.table_exists?(definition[:name])
205
169
 
206
- stats = screenshot || fetch_counts(definition[:model].constantize, definition)
207
- limit = screenshot ? screenshot[:limit] : @config.max_table_records&.[](definition[:name])
170
+ stats = fetch_counts(model, definition)
171
+ limit = @config.max_table_records&.[](definition[:name])
208
172
  count = stats[:count]
209
173
  percent = limit.to_i.positive? ? ((count.to_f / limit) * 100).round(1) : nil
210
174
  severity = fill_severity(percent, count, limit)
@@ -223,7 +187,7 @@ module RailsPulse
223
187
  newest_at: stats[:newest_at],
224
188
  recent_count: stats[:recent_count],
225
189
  runway_label: runway_label(count, limit, stats[:recent_count]),
226
- bytes: screenshot ? screenshot[:bytes] : table_bytes(definition[:name]),
190
+ bytes: measured_table_bytes(definition[:name]),
227
191
  history_label: history_label(stats[:oldest_at], stats[:newest_at])
228
192
  }
229
193
  rescue => error
@@ -351,6 +315,12 @@ module RailsPulse
351
315
  row[key] || row[key.to_s] || row[key.to_s.upcase] || row[key.to_sym]
352
316
  end
353
317
 
318
+ def measured_table_bytes(table_name)
319
+ return table_bytes(table_name) unless @cached_sizes
320
+
321
+ self.class.cached_table_bytes(table_name) { table_bytes(table_name) }
322
+ end
323
+
354
324
  def table_bytes(table_name)
355
325
  adapter = connection.adapter_name.downcase
356
326