rails_pulse 0.3.3 → 0.4.0.pre.2

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 (169) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +299 -0
  3. data/README.md +44 -3
  4. data/app/assets/stylesheets/rails_pulse/application.css +21 -32
  5. data/app/assets/stylesheets/rails_pulse/components/backtrace.css +238 -0
  6. data/app/assets/stylesheets/rails_pulse/components/badge.css +32 -0
  7. data/app/assets/stylesheets/rails_pulse/components/button.css +2 -36
  8. data/app/assets/stylesheets/rails_pulse/components/card.css +8 -11
  9. data/app/assets/stylesheets/rails_pulse/components/chart.css +16 -3
  10. data/app/assets/stylesheets/rails_pulse/components/csp_safe_positioning.css +5 -39
  11. data/app/assets/stylesheets/rails_pulse/components/dashboard.css +0 -52
  12. data/app/assets/stylesheets/rails_pulse/components/deployments.css +39 -0
  13. data/app/assets/stylesheets/rails_pulse/components/dialog.css +0 -5
  14. data/app/assets/stylesheets/rails_pulse/components/input.css +0 -14
  15. data/app/assets/stylesheets/rails_pulse/components/layouts.css +0 -20
  16. data/app/assets/stylesheets/rails_pulse/components/menu.css +0 -16
  17. data/app/assets/stylesheets/rails_pulse/components/sidebar_menu.css +0 -16
  18. data/app/assets/stylesheets/rails_pulse/components/storage.css +187 -0
  19. data/app/assets/stylesheets/rails_pulse/components/tags.css +0 -14
  20. data/app/assets/stylesheets/rails_pulse/components/time_range_selector.css +5 -5
  21. data/app/assets/stylesheets/rails_pulse/components/utilities.css +0 -17
  22. data/app/controllers/concerns/chart_table_concern.rb +15 -3
  23. data/app/controllers/concerns/ransack_params_concern.rb +18 -0
  24. data/app/controllers/concerns/response_range_concern.rb +1 -1
  25. data/app/controllers/concerns/time_range_concern.rb +28 -14
  26. data/app/controllers/rails_pulse/application_controller.rb +104 -31
  27. data/app/controllers/rails_pulse/dashboard_controller.rb +9 -0
  28. data/app/controllers/rails_pulse/deployments_controller.rb +26 -5
  29. data/app/controllers/rails_pulse/exception_occurrences_controller.rb +19 -0
  30. data/app/controllers/rails_pulse/exceptions_controller.rb +119 -0
  31. data/app/controllers/rails_pulse/job_runs_controller.rb +1 -1
  32. data/app/controllers/rails_pulse/requests_controller.rb +1 -1
  33. data/app/controllers/rails_pulse/routes_controller.rb +7 -1
  34. data/app/controllers/rails_pulse/storage_controller.rb +7 -0
  35. data/app/controllers/rails_pulse/tags_controller.rb +2 -2
  36. data/app/helpers/rails_pulse/application_helper.rb +1 -0
  37. data/app/helpers/rails_pulse/backtrace_helper.rb +85 -0
  38. data/app/helpers/rails_pulse/breadcrumbs_helper.rb +4 -1
  39. data/app/helpers/rails_pulse/chart_helper.rb +0 -3
  40. data/app/helpers/rails_pulse/route_helper.rb +8 -16
  41. data/app/javascript/rails_pulse/application.js +29 -8
  42. data/app/javascript/rails_pulse/controllers/clipboard_controller.js +58 -0
  43. data/app/javascript/rails_pulse/controllers/index_controller.js +16 -10
  44. data/app/javascript/rails_pulse/controllers/pagination_controller.js +6 -2
  45. data/app/javascript/rails_pulse/controllers/popover_controller.js +4 -0
  46. data/app/models/concerns/rails_pulse/taggable.rb +11 -6
  47. data/app/models/rails_pulse/cards/base.rb +53 -0
  48. data/app/models/rails_pulse/dashboard/health_summary.rb +52 -0
  49. data/app/models/rails_pulse/dashboard/needs_attention.rb +73 -1
  50. data/app/models/rails_pulse/dashboard/storage_status.rb +490 -0
  51. data/app/models/rails_pulse/deployment.rb +47 -1
  52. data/app/models/rails_pulse/exception_group.rb +37 -0
  53. data/app/models/rails_pulse/exception_occurrence.rb +27 -0
  54. data/app/models/rails_pulse/exceptions/cards/exception_rate.rb +116 -0
  55. data/app/models/rails_pulse/exceptions/cards/open_groups.rb +49 -0
  56. data/app/models/rails_pulse/exceptions/cards/total_occurrences.rb +62 -0
  57. data/app/models/rails_pulse/exceptions/charts/occurrence_volume.rb +56 -0
  58. data/app/models/rails_pulse/exceptions/raw_data.rb +216 -0
  59. data/app/models/rails_pulse/jobs/cards/p95_duration.rb +8 -2
  60. data/app/models/rails_pulse/operation.rb +13 -4
  61. data/app/models/rails_pulse/queries/cards/percentile_query_times.rb +8 -3
  62. data/app/models/rails_pulse/request.rb +5 -1
  63. data/app/models/rails_pulse/route.rb +71 -21
  64. data/app/models/rails_pulse/routes/cards/error_rates.rb +6 -3
  65. data/app/models/rails_pulse/routes/cards/percentile_response_times.rb +8 -3
  66. data/app/models/rails_pulse/routes/tables/index.rb +8 -6
  67. data/app/models/rails_pulse/summary.rb +21 -10
  68. data/app/models/rails_pulse/tables/base.rb +4 -2
  69. data/app/services/rails_pulse/analysis/explain_plan_analyzer.rb +47 -12
  70. data/app/services/rails_pulse/exception_capture_service.rb +228 -0
  71. data/app/services/rails_pulse/exception_message_sanitizer.rb +152 -0
  72. data/app/services/rails_pulse/operations/change_point.rb +144 -0
  73. data/app/services/rails_pulse/operations/compare.rb +92 -0
  74. data/app/services/rails_pulse/operations/comparison.rb +126 -0
  75. data/app/services/rails_pulse/operations/metric.rb +63 -0
  76. data/app/services/rails_pulse/operations/series.rb +84 -0
  77. data/app/services/rails_pulse/operations/subject.rb +77 -0
  78. data/app/services/rails_pulse/route_controller_action_backfiller.rb +110 -0
  79. data/app/services/rails_pulse/route_merger.rb +99 -0
  80. data/app/services/rails_pulse/route_migrator.rb +139 -0
  81. data/app/services/rails_pulse/route_path_normalizer.rb +79 -0
  82. data/app/services/rails_pulse/route_recognizer.rb +47 -0
  83. data/app/services/rails_pulse/summary_service.rb +45 -0
  84. data/app/services/rails_pulse/tag_filter_service.rb +5 -3
  85. data/app/views/layouts/rails_pulse/_menu_items.html.erb +7 -0
  86. data/app/views/layouts/rails_pulse/_onboarding_banner.html.erb +8 -0
  87. data/app/views/layouts/rails_pulse/_setup_screen.html.erb +5 -5
  88. data/app/views/layouts/rails_pulse/application.html.erb +4 -4
  89. data/app/views/rails_pulse/components/_empty_state.html.erb +1 -1
  90. data/app/views/rails_pulse/components/_panel.html.erb +16 -6
  91. data/app/views/rails_pulse/components/_raw_data.html.erb +34 -0
  92. data/app/views/rails_pulse/dashboard/_chart_panel.html.erb +6 -8
  93. data/app/views/rails_pulse/dashboard/_deployments_panel.html.erb +64 -0
  94. data/app/views/rails_pulse/dashboard/_health_badge.html.erb +21 -10
  95. data/app/views/rails_pulse/dashboard/_storage_panel.html.erb +67 -0
  96. data/app/views/rails_pulse/dashboard/index.html.erb +22 -1
  97. data/app/views/rails_pulse/deployments/_status_badge.html.erb +5 -0
  98. data/app/views/rails_pulse/deployments/_table.html.erb +29 -0
  99. data/app/views/rails_pulse/deployments/index.html.erb +15 -0
  100. data/app/views/rails_pulse/deployments/show.html.erb +62 -0
  101. data/app/views/rails_pulse/exception_occurrences/show.html.erb +45 -0
  102. data/app/views/rails_pulse/exceptions/_backtrace.html.erb +33 -0
  103. data/app/views/rails_pulse/exceptions/_metric_cards.html.erb +6 -0
  104. data/app/views/rails_pulse/exceptions/_occurrences_table.html.erb +23 -0
  105. data/app/views/rails_pulse/exceptions/_status_badge.html.erb +6 -0
  106. data/app/views/rails_pulse/exceptions/_table.html.erb +33 -0
  107. data/app/views/rails_pulse/exceptions/index.html.erb +66 -0
  108. data/app/views/rails_pulse/exceptions/show.html.erb +52 -0
  109. data/app/views/rails_pulse/queries/_raw_data.html.erb +1 -23
  110. data/app/views/rails_pulse/requests/_table.html.erb +1 -1
  111. data/app/views/rails_pulse/routes/_requests_table.html.erb +5 -0
  112. data/app/views/rails_pulse/routes/_table.html.erb +22 -8
  113. data/app/views/rails_pulse/routes/index.html.erb +5 -1
  114. data/app/views/rails_pulse/routes/show.html.erb +5 -0
  115. data/app/views/rails_pulse/storage/show.html.erb +123 -0
  116. data/config/routes.rb +13 -4
  117. data/db/rails_pulse_migrate/20260506000001_create_rails_pulse_exceptions.rb +50 -0
  118. data/db/rails_pulse_migrate/20260507000001_add_actual_query_to_operations.rb +10 -5
  119. data/db/rails_pulse_migrate/20260610000001_add_controller_action_to_rails_pulse_routes.rb +8 -0
  120. data/db/rails_pulse_migrate/20260610000002_change_rails_pulse_routes_to_multi_verb_model.rb +185 -0
  121. data/db/rails_pulse_migrate/20260610000003_add_null_action_unique_index_to_routes.rb +32 -0
  122. data/db/rails_pulse_migrate/20260823000001_add_location_to_exception_groups.rb +12 -0
  123. data/db/rails_pulse_schema.rb +54 -6
  124. data/lib/generators/rails_pulse/base_methods.rb +2 -0
  125. data/lib/generators/rails_pulse/install_generator.rb +5 -0
  126. data/lib/generators/rails_pulse/schema_parser.rb +22 -2
  127. data/lib/generators/rails_pulse/templates/db/rails_pulse_schema.rb +54 -6
  128. data/lib/generators/rails_pulse/templates/migrations/upgrade_rails_pulse_tables.rb +1 -1
  129. data/lib/generators/rails_pulse/templates/rails_pulse.rb +157 -16
  130. data/lib/generators/rails_pulse/upgrade_generator.rb +166 -38
  131. data/lib/rails_pulse/cleanup_service.rb +110 -15
  132. data/lib/rails_pulse/configuration.rb +125 -6
  133. data/lib/rails_pulse/engine.rb +32 -17
  134. data/lib/rails_pulse/installers/config_updater.rb +202 -0
  135. data/lib/rails_pulse/job_run_collector.rb +43 -16
  136. data/lib/rails_pulse/middleware/asset_server.rb +11 -0
  137. data/lib/rails_pulse/middleware/request_collector.rb +9 -4
  138. data/lib/rails_pulse/packaged_assets.rb +147 -0
  139. data/lib/rails_pulse/route_indexes.rb +66 -0
  140. data/lib/rails_pulse/subscribers/exception_subscriber.rb +36 -0
  141. data/lib/rails_pulse/tracker.rb +29 -10
  142. data/lib/rails_pulse/version.rb +1 -1
  143. data/lib/rails_pulse.rb +1 -0
  144. data/lib/rails_pulse_server.ru +16 -47
  145. data/lib/tasks/rails_pulse.rake +83 -4
  146. data/lib/tasks/rails_pulse_assets.rake +15 -0
  147. data/lib/tasks/rails_pulse_tasks.rake +29 -0
  148. data/public/rails-pulse-assets/rails-pulse-icons.js +6 -2
  149. data/public/rails-pulse-assets/rails-pulse.css +1 -1
  150. data/public/rails-pulse-assets/rails-pulse.js +18 -117
  151. metadata +78 -37
  152. data/Rakefile +0 -554
  153. data/app/javascript/rails_pulse/controllers/form_controller.js +0 -39
  154. data/app/javascript/rails_pulse/controllers/period_selector_controller.js +0 -19
  155. data/app/javascript/rails_pulse/controllers/timezone_controller.js +0 -89
  156. data/app/javascript/rails_pulse/theme.js +0 -416
  157. data/config/brakeman.ignore +0 -190
  158. data/config/brakeman.yml +0 -68
  159. data/config/initializers/rails_pulse.rb +0 -287
  160. data/public/rails-pulse-assets/csp-test.js +0 -120
  161. data/public/rails-pulse-assets/rails-pulse-icons.js.map +0 -13
  162. data/public/rails-pulse-assets/rails-pulse.css.map +0 -1
  163. data/public/rails-pulse-assets/rails-pulse.js.map +0 -7
  164. data/vendor/assets/javascripts/rails-pulse-icons.js +0 -93
  165. data/vendor/assets/javascripts/rails-pulse-icons.js.map +0 -13
  166. data/vendor/assets/javascripts/rails-pulse.js +0 -157
  167. data/vendor/assets/javascripts/rails-pulse.js.map +0 -7
  168. data/vendor/assets/stylesheets/rails-pulse.css +0 -1
  169. data/vendor/assets/stylesheets/rails-pulse.css.map +0 -1
@@ -0,0 +1,490 @@
1
+ module RailsPulse
2
+ module Dashboard
3
+ class StorageStatus
4
+ TABLE_CATALOG = [
5
+ {
6
+ name: :rails_pulse_operations,
7
+ model: "RailsPulse::Operation",
8
+ label: "Operations",
9
+ description: "SQL, view, cache, and other work captured inside requests and jobs",
10
+ time_column: :occurred_at
11
+ },
12
+ {
13
+ name: :rails_pulse_requests,
14
+ model: "RailsPulse::Request",
15
+ label: "Requests",
16
+ description: "Individual HTTP requests",
17
+ time_column: :occurred_at
18
+ },
19
+ {
20
+ name: :rails_pulse_job_runs,
21
+ model: "RailsPulse::JobRun",
22
+ label: "Job runs",
23
+ description: "Individual background job executions",
24
+ time_column: :occurred_at
25
+ },
26
+ {
27
+ name: :rails_pulse_exception_occurrences,
28
+ model: "RailsPulse::ExceptionOccurrence",
29
+ label: "Exception occurrences",
30
+ description: "Stored exception instances (groups keep a lifetime count after cleanup)",
31
+ time_column: :occurred_at
32
+ },
33
+ {
34
+ name: :rails_pulse_summaries,
35
+ model: "RailsPulse::Summary",
36
+ label: "Summaries",
37
+ description: "Hourly and daily aggregates used by charts. Hourly rows older than 2 days are cleaned up",
38
+ time_column: :period_start
39
+ },
40
+ {
41
+ name: :rails_pulse_queries,
42
+ model: "RailsPulse::Query",
43
+ label: "Queries",
44
+ description: "Normalized SQL query fingerprints",
45
+ time_column: :created_at
46
+ },
47
+ {
48
+ name: :rails_pulse_routes,
49
+ model: "RailsPulse::Route",
50
+ label: "Routes",
51
+ description: "Unique request paths",
52
+ time_column: :created_at
53
+ },
54
+ {
55
+ name: :rails_pulse_jobs,
56
+ model: "RailsPulse::Job",
57
+ label: "Jobs",
58
+ description: "Unique background job class names",
59
+ time_column: :created_at
60
+ },
61
+ {
62
+ name: :rails_pulse_exception_groups,
63
+ model: "RailsPulse::ExceptionGroup",
64
+ label: "Exception groups",
65
+ description: "Distinct exception sites. Preserved groups are never deleted",
66
+ time_column: :last_seen_at
67
+ },
68
+ {
69
+ name: :rails_pulse_deployments,
70
+ model: "RailsPulse::Deployment",
71
+ label: "Deployments",
72
+ description: "Deploy markers shown on charts",
73
+ time_column: :started_at
74
+ }
75
+ ].freeze
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
118
+
119
+ def initialize
120
+ @config = RailsPulse.configuration
121
+ @pressure = StoragePressure.new
122
+ end
123
+
124
+ def tables
125
+ @tables ||= TABLE_CATALOG.filter_map { |definition| build_table(definition) }
126
+ end
127
+
128
+ def pressure_items
129
+ @pressure.pressure_items
130
+ end
131
+
132
+ def cleanup
133
+ @cleanup ||= {
134
+ enabled: @config.archiving_enabled,
135
+ retention_period: @config.full_retention_period,
136
+ retention_label: humanize_duration(@config.full_retention_period),
137
+ last_summary_at: last_hourly_summary_end,
138
+ health: cleanup_health,
139
+ health_label: cleanup_health_label
140
+ }
141
+ end
142
+
143
+ def database
144
+ @database ||= {
145
+ adapter: adapter_label,
146
+ separate: RailsPulse.connects_to.present?,
147
+ setup_label: RailsPulse.connects_to.present? ? "Separate database" : "Shared with host app",
148
+ database_name: database_name,
149
+ file_size: sqlite_file_size,
150
+ table_bytes: tables_with_size? ? tables.sum { |table| table[:bytes].to_i } : nil,
151
+ size_available: size_available?,
152
+ size_note: size_note
153
+ }
154
+ end
155
+
156
+ def dashboard_tables
157
+ capped = tables.select { |table| table[:limit] }
158
+ return tables.first(4) if capped.empty?
159
+
160
+ capped.sort_by { |table| -table[:percent].to_f }.first(4)
161
+ end
162
+
163
+ def overview
164
+ hottest = hottest_capped_table
165
+ {
166
+ hottest_label: hottest ? hottest[:label] : "—",
167
+ hottest_percent: hottest ? hottest[:percent] : nil,
168
+ total_records: tables.sum { |table| table[:count] },
169
+ display_bytes: display_bytes,
170
+ cleanup_health: cleanup[:health],
171
+ cleanup_label: cleanup[:health_label]
172
+ }
173
+ end
174
+
175
+ private
176
+
177
+ def connection
178
+ RailsPulse::ApplicationRecord.connection
179
+ end
180
+
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
+ 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
205
+
206
+ stats = screenshot || fetch_counts(definition[:model].constantize, definition)
207
+ limit = screenshot ? screenshot[:limit] : @config.max_table_records&.[](definition[:name])
208
+ count = stats[:count]
209
+ percent = limit.to_i.positive? ? ((count.to_f / limit) * 100).round(1) : nil
210
+ severity = fill_severity(percent, count, limit)
211
+
212
+ {
213
+ name: definition[:name],
214
+ label: definition[:label],
215
+ description: definition[:description],
216
+ count: count,
217
+ limit: limit,
218
+ percent: percent,
219
+ percent_label: percent_label(percent),
220
+ bar_width: bar_width(percent),
221
+ severity: severity,
222
+ oldest_at: stats[:oldest_at],
223
+ newest_at: stats[:newest_at],
224
+ recent_count: stats[:recent_count],
225
+ runway_label: runway_label(count, limit, stats[:recent_count]),
226
+ bytes: screenshot ? screenshot[:bytes] : table_bytes(definition[:name]),
227
+ history_label: history_label(stats[:oldest_at], stats[:newest_at])
228
+ }
229
+ rescue => error
230
+ RailsPulse.logger.warn("StorageStatus failed for #{definition[:name]}: #{error.message}")
231
+ {
232
+ name: definition[:name],
233
+ label: definition[:label],
234
+ description: definition[:description],
235
+ count: 0,
236
+ limit: @config.max_table_records&.[](definition[:name]),
237
+ percent: nil,
238
+ percent_label: nil,
239
+ bar_width: 0,
240
+ severity: :error,
241
+ oldest_at: nil,
242
+ newest_at: nil,
243
+ recent_count: 0,
244
+ runway_label: "Error loading",
245
+ bytes: nil,
246
+ history_label: error.message
247
+ }
248
+ end
249
+
250
+ def fetch_counts(model, definition)
251
+ quoted_table = connection.quote_table_name(definition[:name])
252
+ time_column = definition[:time_column]
253
+
254
+ unless time_column && model.column_names.include?(time_column.to_s)
255
+ count = connection.select_value("SELECT COUNT(*) FROM #{quoted_table}").to_i
256
+ return { count: count, oldest_at: nil, newest_at: nil, recent_count: 0 }
257
+ end
258
+
259
+ quoted_column = connection.quote_column_name(time_column)
260
+ row = connection.select_one(
261
+ model.sanitize_sql_array([ <<~SQL, 1.day.ago ])
262
+ SELECT COUNT(*) AS record_count,
263
+ MIN(#{quoted_column}) AS oldest_at,
264
+ MAX(#{quoted_column}) AS newest_at,
265
+ SUM(CASE WHEN #{quoted_column} >= ? THEN 1 ELSE 0 END) AS recent_count
266
+ FROM #{quoted_table}
267
+ SQL
268
+ ) || {}
269
+
270
+ {
271
+ count: row_value(row, "record_count").to_i,
272
+ oldest_at: parse_time(row_value(row, "oldest_at")),
273
+ newest_at: parse_time(row_value(row, "newest_at")),
274
+ recent_count: row_value(row, "recent_count").to_i
275
+ }
276
+ end
277
+
278
+ def fill_severity(percent, count, limit)
279
+ return :uncapped if limit.nil?
280
+ return :critical if count > limit || percent.to_f >= 90
281
+ return :warning if percent.to_f >= 70
282
+
283
+ :healthy
284
+ end
285
+
286
+ def percent_label(percent)
287
+ return if percent.nil?
288
+ return "< 0.1%" if percent < 0.1
289
+
290
+ "#{percent}%"
291
+ end
292
+
293
+ def bar_width(percent)
294
+ return 0 if percent.nil?
295
+
296
+ [ [ percent, 0 ].max, 100 ].min
297
+ end
298
+
299
+ def runway_label(count, limit, recent_count)
300
+ return "No cap" if limit.nil?
301
+ return "Over cap" if count >= limit
302
+ return "No recent growth" if recent_count.to_i.zero?
303
+
304
+ days = (limit - count).to_f / recent_count
305
+ if days < 1
306
+ "< 1 day to cap"
307
+ elsif days < 90
308
+ "~#{days.round} #{'day'.pluralize(days.round)} to cap"
309
+ else
310
+ "Well under cap"
311
+ end
312
+ end
313
+
314
+ def history_label(oldest_at, newest_at)
315
+ return "No rows" unless oldest_at && newest_at
316
+
317
+ span_days = ((newest_at - oldest_at) / 1.day).round
318
+ span = if span_days < 1
319
+ "< 1 day of history"
320
+ else
321
+ "#{span_days} #{'day'.pluralize(span_days)} of history"
322
+ end
323
+
324
+ "#{span} · oldest #{time_ago(oldest_at)}"
325
+ end
326
+
327
+ def time_ago(time)
328
+ seconds = [ Time.current - time, 0 ].max
329
+ if seconds < 60
330
+ "just now"
331
+ elsif seconds < 3_600
332
+ "#{(seconds / 60).to_i}m ago"
333
+ elsif seconds < 86_400
334
+ "#{(seconds / 3_600).to_i}h ago"
335
+ else
336
+ days = (seconds / 86_400).to_i
337
+ "#{days}d ago"
338
+ end
339
+ end
340
+
341
+ def parse_time(value)
342
+ return if value.blank?
343
+ return value.in_time_zone if value.respond_to?(:in_time_zone) && !value.is_a?(String)
344
+
345
+ (Time.zone || Time).parse(value.to_s)
346
+ rescue ArgumentError, TypeError
347
+ nil
348
+ end
349
+
350
+ def row_value(row, key)
351
+ row[key] || row[key.to_s] || row[key.to_s.upcase] || row[key.to_sym]
352
+ end
353
+
354
+ def table_bytes(table_name)
355
+ adapter = connection.adapter_name.downcase
356
+
357
+ if adapter.include?("postgres")
358
+ connection.select_value(
359
+ "SELECT pg_total_relation_size(#{connection.quote(table_name.to_s)})"
360
+ ).to_i
361
+ elsif adapter.include?("mysql")
362
+ connection.select_value(
363
+ RailsPulse::ApplicationRecord.sanitize_sql_array([ <<~SQL, table_name.to_s ])
364
+ SELECT DATA_LENGTH + INDEX_LENGTH
365
+ FROM information_schema.TABLES
366
+ WHERE TABLE_SCHEMA = DATABASE()
367
+ AND TABLE_NAME = ?
368
+ SQL
369
+ )&.to_i
370
+ elsif adapter.include?("sqlite")
371
+ sqlite_table_bytes(table_name)
372
+ end
373
+ rescue ActiveRecord::StatementInvalid
374
+ nil
375
+ end
376
+
377
+ def sqlite_table_bytes(table_name)
378
+ connection.select_value(
379
+ RailsPulse::ApplicationRecord.sanitize_sql_array([
380
+ "SELECT SUM(pgsize) FROM dbstat WHERE name = ?",
381
+ table_name.to_s
382
+ ])
383
+ )&.to_i
384
+ rescue ActiveRecord::StatementInvalid
385
+ nil
386
+ end
387
+
388
+ def sqlite_file_size
389
+ return unless connection.adapter_name.downcase.include?("sqlite")
390
+ return unless RailsPulse.connects_to.present?
391
+
392
+ path = database_name
393
+ return unless path.present? && File.exist?(path)
394
+
395
+ File.size(path)
396
+ rescue StandardError
397
+ nil
398
+ end
399
+
400
+ def database_name
401
+ config = RailsPulse::ApplicationRecord.connection_db_config
402
+ config.respond_to?(:database) ? config.database : config.configuration_hash[:database]
403
+ rescue StandardError
404
+ nil
405
+ end
406
+
407
+ def adapter_label
408
+ name = connection.adapter_name
409
+ case name.downcase
410
+ when /postgres/ then "PostgreSQL"
411
+ when /mysql/ then "MySQL"
412
+ when /sqlite/ then "SQLite"
413
+ else name
414
+ end
415
+ end
416
+
417
+ def tables_with_size?
418
+ tables.any? { |table| table[:bytes].present? }
419
+ end
420
+
421
+ def size_available?
422
+ tables_with_size? || sqlite_file_size.present?
423
+ end
424
+
425
+ def display_bytes
426
+ return sqlite_file_size if sqlite_file_size
427
+ return tables.sum { |table| table[:bytes].to_i } if tables_with_size?
428
+
429
+ nil
430
+ end
431
+
432
+ def size_note
433
+ adapter = connection.adapter_name.downcase
434
+ if adapter.include?("sqlite") && RailsPulse.connects_to.blank?
435
+ "SQLite is sharing the host database, so file size would include the rest of the app. Per-table size is shown when SQLite dbstat is available."
436
+ elsif adapter.include?("sqlite") && RailsPulse.connects_to.present?
437
+ "File size of the dedicated Rails Pulse SQLite database."
438
+ elsif adapter.include?("postgres")
439
+ "PostgreSQL relation size including indexes (pg_total_relation_size)."
440
+ elsif adapter.include?("mysql")
441
+ "MySQL data + index length from information_schema."
442
+ end
443
+ end
444
+
445
+ def hottest_capped_table
446
+ tables.select { |table| table[:percent] }.max_by { |table| table[:percent] }
447
+ end
448
+
449
+ def last_hourly_summary_end
450
+ RailsPulse::Summary
451
+ .where(summarizable_type: "RailsPulse::Request", summarizable_id: 0, period_type: "hour")
452
+ .maximum(:period_end)
453
+ end
454
+
455
+ def cleanup_health
456
+ items = pressure_items
457
+ return :critical if items.any? { |item| item[:severity] == :critical }
458
+ return :warning if items.any? { |item| item[:severity] == :warning }
459
+ return :disabled unless @config.archiving_enabled
460
+
461
+ :healthy
462
+ end
463
+
464
+ def cleanup_health_label
465
+ case cleanup_health
466
+ when :critical then "Cleanup blocked"
467
+ when :warning then "Needs attention"
468
+ when :disabled then "Cleanup off"
469
+ else "Healthy"
470
+ end
471
+ end
472
+
473
+ def humanize_duration(duration)
474
+ return "Not set" unless duration
475
+
476
+ total_seconds = duration.to_i
477
+ if total_seconds >= 86_400
478
+ days = total_seconds / 86_400
479
+ "#{days} #{'day'.pluralize(days)}"
480
+ elsif total_seconds >= 3_600
481
+ hours = total_seconds / 3_600
482
+ "#{hours} #{'hour'.pluralize(hours)}"
483
+ else
484
+ minutes = total_seconds / 60
485
+ "#{minutes} #{'minute'.pluralize(minutes)}"
486
+ end
487
+ end
488
+ end
489
+ end
490
+ end
@@ -2,8 +2,18 @@ module RailsPulse
2
2
  class Deployment < RailsPulse::ApplicationRecord
3
3
  self.table_name = "rails_pulse_deployments"
4
4
 
5
- validates :revision, presence: true
5
+ # The create endpoint is reachable with a CI token, so every field a
6
+ # caller controls is bounded: a leaked token must not be able to fill the
7
+ # table or push a marker into the future (which would stamp every later
8
+ # exception with a revision that has not shipped).
9
+ MAX_REVISION_LENGTH = 255
10
+ MAX_METADATA_BYTES = 4.kilobytes
11
+ MAX_CLOCK_SKEW = 1.hour
12
+
13
+ validates :revision, presence: true, length: { maximum: MAX_REVISION_LENGTH }
6
14
  validates :started_at, presence: true
15
+ validate :started_at_is_not_in_the_future
16
+ validate :metadata_is_within_size_limit
7
17
 
8
18
  scope :for_range, ->(start_time, end_time) { where(started_at: start_time..end_time) }
9
19
  scope :recent, -> { order(started_at: :desc) }
@@ -16,6 +26,26 @@ module RailsPulse
16
26
  []
17
27
  end
18
28
 
29
+ # Breadcrumbs fall back to #to_s without this, which renders the object's
30
+ # inspect string. Revisions can be full 40-char SHAs, so truncate.
31
+ def to_breadcrumb
32
+ short_revision
33
+ end
34
+
35
+ def short_revision
36
+ revision.to_s.length > 12 ? revision.to_s.first(12) : revision.to_s
37
+ end
38
+
39
+ def in_progress?
40
+ finished_at.blank?
41
+ end
42
+
43
+ # Wall-clock seconds the deploy took, or nil while it is still running.
44
+ def duration
45
+ return nil if in_progress? || started_at.blank?
46
+ finished_at - started_at
47
+ end
48
+
19
49
  def metadata_hash
20
50
  return {} if metadata.blank?
21
51
  JSON.parse(metadata)
@@ -26,5 +56,21 @@ module RailsPulse
26
56
  def to_chart_marker
27
57
  { timestamp: started_at.to_i * 1000, revision: revision, started_at: started_at.iso8601 }
28
58
  end
59
+
60
+ private
61
+
62
+ def started_at_is_not_in_the_future
63
+ return if started_at.blank?
64
+ return if started_at <= MAX_CLOCK_SKEW.from_now
65
+
66
+ errors.add(:started_at, "can't be more than #{MAX_CLOCK_SKEW.inspect} in the future")
67
+ end
68
+
69
+ def metadata_is_within_size_limit
70
+ return if metadata.blank?
71
+ return if metadata.to_s.bytesize <= MAX_METADATA_BYTES
72
+
73
+ errors.add(:metadata, "is too large (maximum is #{MAX_METADATA_BYTES.to_i / 1024} KB)")
74
+ end
29
75
  end
30
76
  end
@@ -0,0 +1,37 @@
1
+ module RailsPulse
2
+ class ExceptionGroup < RailsPulse::ApplicationRecord
3
+ self.table_name = "rails_pulse_exception_groups"
4
+
5
+ STATUSES = %w[open resolved ignored].freeze
6
+
7
+ # SQLite schema dumps omit boolean DEFAULT false; without a model default,
8
+ # AR inserts NULL and SQLite rejects the NOT NULL constraint.
9
+ attribute :preserve, :boolean, default: false
10
+
11
+ has_many :occurrences, class_name: "RailsPulse::ExceptionOccurrence",
12
+ foreign_key: :exception_group_id, dependent: :destroy
13
+
14
+ validates :fingerprint, presence: true
15
+ validates :exception_class, presence: true
16
+ validates :first_seen_at, :last_seen_at, presence: true
17
+ validates :status, inclusion: { in: STATUSES }
18
+
19
+ scope :recent, -> { order(last_seen_at: :desc) }
20
+ scope :by_class, ->(klass) { where(exception_class: klass) }
21
+ scope :open, -> { where(status: "open") }
22
+ scope :resolved, -> { where(status: "resolved") }
23
+ scope :preservable, -> { where(preserve: false) }
24
+
25
+ def self.ransackable_attributes(auth_object = nil)
26
+ %w[id exception_class fingerprint location first_seen_at last_seen_at occurrence_count status resolved_at preserve]
27
+ end
28
+
29
+ def self.ransackable_associations(auth_object = nil)
30
+ %w[occurrences]
31
+ end
32
+
33
+ def to_breadcrumb
34
+ exception_class
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,27 @@
1
+ module RailsPulse
2
+ class ExceptionOccurrence < RailsPulse::ApplicationRecord
3
+ self.table_name = "rails_pulse_exception_occurrences"
4
+
5
+ belongs_to :exception_group, class_name: "RailsPulse::ExceptionGroup"
6
+
7
+ serialize :backtrace, type: Array, coder: JSON
8
+ serialize :request_params, type: Hash, coder: JSON
9
+
10
+ validates :exception_class, presence: true
11
+ validates :occurred_at, presence: true
12
+
13
+ scope :recent, -> { order(occurred_at: :desc) }
14
+
15
+ def self.ransackable_attributes(auth_object = nil)
16
+ %w[id exception_class message occurred_at request_url environment]
17
+ end
18
+
19
+ def self.ransackable_associations(auth_object = nil)
20
+ %w[exception_group]
21
+ end
22
+
23
+ def to_breadcrumb
24
+ occurred_at.getlocal.strftime("%b %d, %Y %l:%M %p")
25
+ end
26
+ end
27
+ end