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,66 @@
1
+ <%= render 'rails_pulse/components/page_header', show_active_filters: true %>
2
+
3
+ <%= render 'rails_pulse/exceptions/metric_cards' %>
4
+
5
+ <%= render 'rails_pulse/components/panel', {
6
+ title: 'Exceptions',
7
+ card_classes: 'b-full',
8
+ help_heading: 'Exception Frequency',
9
+ help_text: 'How often exceptions were raised over the selected period, across every group. Read from retained summaries rather than individual occurrence records, so the history stays accurate after cleanup has pruned old occurrences.'
10
+ } do %>
11
+ <div class="filter-bar">
12
+ <%= search_form_for @ransack_query, url: exceptions_path, class: "filter-bar__form" do |form| %>
13
+ <%= form.search_field :exception_class_cont,
14
+ placeholder: "Filter by exception class",
15
+ autocomplete: "off",
16
+ class: "input"
17
+ %>
18
+ <%= form.select :status_eq,
19
+ options_for_select(
20
+ [["All", ""]] + RailsPulse::ExceptionGroup::STATUSES.map { |s| [s.capitalize, s] },
21
+ @ransack_query.status_eq
22
+ ),
23
+ {},
24
+ { class: "input" }
25
+ %>
26
+ <%= if params.has_key?(:q)
27
+ link_to "Reset",
28
+ exceptions_path,
29
+ class: "btn btn--plain",
30
+ style: "text-decoration: none; color: var(--color-text-muted);"
31
+ end %>
32
+ <%= form.submit "Search", class: "btn" %>
33
+ <% end %>
34
+ </div>
35
+
36
+ <% if @table_data.any? %>
37
+ <% if @occurrence_volume_chart %>
38
+ <%# No chart tabs on this page — the toggles list holds the deploy toggle alone. %>
39
+ <div class="panel-tabs">
40
+ <div class="panel-tabs__list">
41
+ <%= render "rails_pulse/shared/deployment_markers_toggle" %>
42
+ </div>
43
+ </div>
44
+
45
+ <div class="chart-container chart-container--slim">
46
+ <%= render_stimulus_chart(
47
+ @occurrence_volume_chart,
48
+ type: "bar",
49
+ id: "exception_occurrence_volume_chart",
50
+ height: "100%",
51
+ options: bar_chart_options.deep_merge(series: { barMaxWidth: 48 })
52
+ ) %>
53
+ </div>
54
+ <% end %>
55
+
56
+ <div data-controller="rails-pulse--table-sort">
57
+ <div id="index_table" data-rails-pulse--table-sort-target="tableFrame">
58
+ <%= render 'rails_pulse/exceptions/table' %>
59
+ </div>
60
+ </div>
61
+ <% else %>
62
+ <%= render 'rails_pulse/components/empty_state',
63
+ title: 'No exceptions recorded.',
64
+ description: 'Exceptions raised by your application will appear here.' %>
65
+ <% end %>
66
+ <% end %>
@@ -0,0 +1,52 @@
1
+ <%= render 'rails_pulse/components/page_header' %>
2
+
3
+ <% status_actions = [
4
+ { label: "Resolve", url: exception_path(@exception_group), method: :patch, params: { status: "resolved" }, class: "btn", disabled: @exception_group.status == "resolved" },
5
+ { label: "Ignore", url: exception_path(@exception_group), method: :patch, params: { status: "ignored" }, class: "btn", disabled: @exception_group.status == "ignored" },
6
+ { label: "Open", url: exception_path(@exception_group), method: :patch, params: { status: "open" }, class: "btn", disabled: @exception_group.status == "open" },
7
+ { label: @exception_group.preserve? ? "Unpreserve" : "Preserve",
8
+ url: exception_path(@exception_group), method: :patch,
9
+ params: { preserve: !@exception_group.preserve? }, class: "btn" }
10
+ ] %>
11
+ <%= render 'rails_pulse/components/panel', title: 'Exception Details', actions: status_actions do %>
12
+ <dl class="descriptive-list mbs-4">
13
+ <dt>Class</dt>
14
+ <dd><code><%= @exception_group.exception_class %></code></dd>
15
+ <dt>Location</dt>
16
+ <dd><code><%= @exception_group.location.presence || "—" %></code></dd>
17
+ <dt>Message</dt>
18
+ <dd><%= @exception_group.message.presence || "—" %></dd>
19
+ <dt>Status</dt>
20
+ <dd><%= render 'rails_pulse/exceptions/status_badge', group: @exception_group %></dd>
21
+ <dt>First Seen</dt>
22
+ <dd><%= human_readable_occurred_at(@exception_group.first_seen_at) %></dd>
23
+ <dt>Last Seen</dt>
24
+ <dd><%= human_readable_occurred_at(@exception_group.last_seen_at) %></dd>
25
+ <dt>Total Seen</dt>
26
+ <dd><%= number_with_delimiter(@exception_group.occurrence_count) %></dd>
27
+ <dt>Stored Occurrences</dt>
28
+ <dd><%= number_with_delimiter(@exception_group.occurrences.count) %></dd>
29
+ <dt>Preserve</dt>
30
+ <dd><%= @exception_group.preserve? ? "Yes — exempt from cleanup" : "No" %></dd>
31
+ </dl>
32
+ <% end %>
33
+
34
+ <% if @latest_occurrence&.backtrace.present? %>
35
+ <%= render 'rails_pulse/components/panel', title: 'Most Recent Backtrace' do %>
36
+ <%= render 'rails_pulse/exceptions/backtrace', backtrace: @latest_occurrence.backtrace %>
37
+ <% end %>
38
+ <% end %>
39
+
40
+ <%= render 'rails_pulse/components/panel', title: 'Occurrences' do %>
41
+ <% if @occurrences.any? %>
42
+ <%= render 'rails_pulse/exceptions/occurrences_table' %>
43
+ <% else %>
44
+ <%= render 'rails_pulse/components/empty_state',
45
+ title: 'No occurrences found.',
46
+ description: '' %>
47
+ <% end %>
48
+ <% end %>
49
+
50
+ <%= render 'rails_pulse/components/raw_data',
51
+ title: 'Raw Data',
52
+ content: RailsPulse::Exceptions::RawData.new(@exception_group, occurrence: @latest_occurrence).to_s %>
@@ -58,26 +58,4 @@
58
58
  json_output = JSON.pretty_generate(raw_data.compact)
59
59
  %>
60
60
 
61
- <div
62
- data-controller="rails-pulse--collapsible"
63
- data-rails-pulse--collapsible-collapsed-class="collapsed"
64
- class="collapsible-code"
65
- >
66
- <h2 class="grow font-semibold leading-none mbe-1 uppercase text-xs">
67
- Raw Data
68
- <button
69
- type="button"
70
- class="collapsible-toggle"
71
- data-rails-pulse--collapsible-target="toggle"
72
- data-action="click->rails-pulse--collapsible#toggle"
73
- >show</button>
74
- <button
75
- type="button"
76
- class="collapsible-toggle"
77
- onclick="navigator.clipboard.writeText(this.closest('.collapsible-code').querySelector('code').textContent).then(() => { this.textContent = 'copied!'; setTimeout(() => this.textContent = 'copy', 1500) })"
78
- >copy</button>
79
- </h2>
80
- <div class="prose max-i-none" data-rails-pulse--collapsible-target="content">
81
- <pre class="mbs-0" style="white-space: pre; margin-block-end: 0; margin-block-start: 0;"><code><%= html_escape(json_output) %></code></pre>
82
- </div>
83
- </div>
61
+ <%= render 'rails_pulse/components/raw_data', content: json_output %>
@@ -23,7 +23,7 @@
23
23
  %>
24
24
  <tr>
25
25
  <td class="truncate-cell">
26
- <%= link_to "#{request.route.path} #{request.route.method}", route_path(request.route) %>
26
+ <%= link_to request.route.path_and_method, route_path(request.route) %>
27
27
  </td>
28
28
  <td class="whitespace-nowrap">
29
29
  <%= link_to human_readable_occurred_at(request.occurred_at), request_path(request) %>
@@ -1,5 +1,6 @@
1
1
  <% columns = [
2
2
  { field: :occurred_at, label: 'Timestamp', class: 'w-auto' },
3
+ { field: :http_method, label: 'Method', class: 'w-20' },
3
4
  { field: :duration, label: 'Response Time', class: 'w-36' },
4
5
  { field: :status, label: 'Status', class: 'w-20' },
5
6
  { field: nil, label: 'Tags', class: 'w-32' }
@@ -14,6 +15,10 @@
14
15
  <td class="whitespace-nowrap">
15
16
  <%= link_to human_readable_occurred_at(request.occurred_at), request_path(request) %>
16
17
  </td>
18
+ <td class="whitespace-nowrap">
19
+ <% verb = request.method %>
20
+ <span class="badge badge--<%= verb&.downcase %>"><%= verb %></span>
21
+ </td>
17
22
  <td class="whitespace-nowrap">
18
23
  <% performance_class = case request.duration
19
24
  when 0..100 then "text-green-600"
@@ -1,9 +1,11 @@
1
1
  <% columns = [
2
- { field: :route_path, label: 'Route', class: 'w-auto', cell_class: 'truncate-cell' },
3
- { field: :p95_duration_sort, label: 'P95', class: 'w-32' },
4
- { field: :p99_duration_sort, label: 'P99', class: 'w-32' },
5
- { field: :count_sort, label: 'Requests', class: 'w-24' },
6
- { field: nil, label: 'Tags', class: 'w-32' }
2
+ { field: :route_path, label: "Path", class: "w-auto", cell_class: "truncate-cell" },
3
+ { field: :route_methods_sort, label: "Methods", class: "w-32" },
4
+ { field: :route_controller_action, label: "Action", class: "w-48", cell_class: "truncate-cell" },
5
+ { field: :p95_duration_sort, label: "P95", class: "w-32" },
6
+ { field: :p99_duration_sort, label: "P99", class: "w-32" },
7
+ { field: :count_sort, label: "Requests", class: "w-24" },
8
+ { field: nil, label: "Tags", class: "w-32" },
7
9
  ] %>
8
10
 
9
11
  <table class="table table-fixed mbs-4">
@@ -12,9 +14,21 @@
12
14
  <tbody>
13
15
  <% @table_data.each do |summary| %>
14
16
  <tr>
15
- <td class="truncate-cell"><%= link_to "#{summary.path} #{summary.route_method}", route_path(summary.route_id) %></td>
16
- <td class="whitespace-nowrap"><%= summary.p95_duration.to_i %> ms</td>
17
- <td class="whitespace-nowrap"><%= summary.p99_duration.to_i %> ms</td>
17
+ <td class="truncate-cell">
18
+ <%= link_to route_path(summary.route_id), class: 'no-underline' do %>
19
+ <%= summary.path %>
20
+ <% end %>
21
+ </td>
22
+ <td class="whitespace-nowrap">
23
+ <% (JSON.parse(summary.route_methods || "[]") rescue []).sort.each do |verb| %>
24
+ <span class="badge badge--<%= verb.downcase %>"><%= verb %></span>
25
+ <% end %>
26
+ </td>
27
+ <td class="truncate-cell text-muted"><%= summary.controller_action %></td>
28
+ <td class="whitespace-nowrap"><%= summary.p95_duration.to_i %>
29
+ ms</td>
30
+ <td class="whitespace-nowrap"><%= summary.p99_duration.to_i %>
31
+ ms</td>
18
32
  <td class="whitespace-nowrap"><%= number_with_delimiter summary.count %></td>
19
33
  <td class="whitespace-nowrap"><%= display_tag_badges(summary.tags) %></td>
20
34
  </tr>
@@ -25,7 +25,11 @@
25
25
  data-rails-pulse--chart-switcher-target="chartTypeInput"
26
26
  >
27
27
  <%= form.search_field :route_path_cont,
28
- placeholder: "Filter by route",
28
+ placeholder: "Filter by path",
29
+ autocomplete: "off",
30
+ class: "input" %>
31
+ <%= form.search_field :route_controller_action_cont,
32
+ placeholder: "Filter by action",
29
33
  autocomplete: "off",
30
34
  class: "input" %>
31
35
  <%= form.select :avg_duration,
@@ -29,6 +29,11 @@
29
29
  { selected: @selected_response_range },
30
30
  { class: "input" }
31
31
  %>
32
+ <%= form.select :http_method_eq,
33
+ [ [ "All Methods", "" ] ] + @route.http_methods_list.sort.map { |m| [ m, m ] },
34
+ { selected: params.dig(:q, :http_method_eq) },
35
+ { class: "input" }
36
+ %>
32
37
  <%= link_to "Reset", route_path(@route), class: "btn btn--plain", style: "text-decoration: none; color: var(--color-text-muted);" if params.has_key?(:q) %>
33
38
  <%= form.submit "Search", class: "btn" %>
34
39
  <% end %>
@@ -0,0 +1,123 @@
1
+ <%= render "rails_pulse/components/page_header" %>
2
+
3
+ <div class="row">
4
+ <div class="card storage-overview-card">
5
+ <p class="font-semibold uppercase text-xs text-subtle mbe-1">Highest fill</p>
6
+ <p class="text-xl font-bold"><% if @status.overview[:hottest_percent] %><%= @status.overview[:hottest_percent].round %>%<% else %>--<% end %></p>
7
+ <p class="text-xs text-subtle"><%= @status.overview[:hottest_label] %></p>
8
+ </div>
9
+ <div class="card storage-overview-card">
10
+ <p class="font-semibold uppercase text-xs text-subtle mbe-1">Records</p>
11
+ <p class="text-xl font-bold"><%= number_with_delimiter(@status.overview[:total_records]) %></p>
12
+ <p class="text-xs text-subtle">Across <%= @status.tables.size %> tables</p>
13
+ </div>
14
+ <div class="card storage-overview-card">
15
+ <p class="font-semibold uppercase text-xs text-subtle mbe-1">Disk used</p>
16
+ <p class="text-xl font-bold"><%= @status.overview[:display_bytes] ? human_readable_bytes(@status.overview[:display_bytes]) : "—" %></p>
17
+ <p class="text-xs text-subtle"><%= @status.database[:adapter] %> · <%= @status.database[:setup_label] %></p>
18
+ </div>
19
+ <div class="card storage-overview-card">
20
+ <p class="font-semibold uppercase text-xs text-subtle mbe-1">Cleanup</p>
21
+ <p class="text-xl font-bold storage-health storage-health--<%= @status.overview[:cleanup_health] %>"><%= @status.overview[:cleanup_label] %></p>
22
+ <p class="text-xs text-subtle">Retain <%= @status.cleanup[:retention_label] %></p>
23
+ </div>
24
+ </div>
25
+
26
+ <% if @status.pressure_items.any? %>
27
+ <div class="card">
28
+ <div class="flex items-center mbe-3">
29
+ <h2 class="font-semibold leading-none uppercase text-xs">Needs attention</h2>
30
+ <span class="badge badge--negative mis-2 text-xs"><%= @status.pressure_items.size %></span>
31
+ </div>
32
+ <% @status.pressure_items.each do |item| %>
33
+ <%= render "rails_pulse/dashboard/attention_item", item: item, severity: item[:severity].to_s %>
34
+ <% end %>
35
+ </div>
36
+ <% end %>
37
+
38
+ <%= render "rails_pulse/components/panel",
39
+ title: "Tables",
40
+ help_heading: "Count caps",
41
+ help_text: "Fill is measured against max_table_records. Cleanup deletes oldest rows after the retention window, then trims down to these caps. Tables with no cap grow until time-based cleanup (if any) removes them." do %>
42
+ <div class="storage-tables mbs-4">
43
+ <% @status.tables.each do |table| %>
44
+ <div class="storage-table">
45
+ <div class="storage-table__header">
46
+ <div>
47
+ <h3 class="font-semibold text-sm"><%= table[:label] %></h3>
48
+ <p class="text-xs text-subtle"><%= table[:description] %></p>
49
+ </div>
50
+ <div class="storage-table__counts">
51
+ <span class="font-semibold text-sm"><%= number_with_delimiter(table[:count]) %></span>
52
+ <% if table[:limit] %>
53
+ <span class="text-xs text-subtle"> / <%= number_with_delimiter(table[:limit]) %></span>
54
+ <span class="badge storage-table__percent storage-table__percent--<%= table[:severity] %>"><%= table[:percent_label] %></span>
55
+ <% else %>
56
+ <span class="badge storage-table__percent storage-table__percent--uncapped">No cap</span>
57
+ <% end %>
58
+ </div>
59
+ </div>
60
+ <div class="storage-fill" aria-hidden="true">
61
+ <div class="storage-fill__bar storage-fill__bar--<%= table[:severity] %>" style="width: <%= table[:bar_width] %>%"></div>
62
+ </div>
63
+ <div class="storage-table__meta text-xs text-subtle">
64
+ <span><%= table[:history_label] %></span>
65
+ <span><%= number_with_delimiter(table[:recent_count]) %> in last 24h</span>
66
+ <span><%= table[:runway_label] %></span>
67
+ <span><%= table[:bytes] ? human_readable_bytes(table[:bytes]) : "Size unknown" %></span>
68
+ </div>
69
+ </div>
70
+ <% end %>
71
+ </div>
72
+ <% end %>
73
+
74
+ <div class="row">
75
+ <%= render "rails_pulse/components/panel",
76
+ title: "Cleanup",
77
+ help_heading: "How cleanup works",
78
+ help_text: "Time-based cleanup deletes records older than full_retention_period, but only after that period has been summarised. Count-based cleanup then trims each table down to max_table_records. Schedule RailsPulse::CleanupJob daily." do %>
79
+ <dl class="descriptive-list mbs-4">
80
+ <dt>Automatic cleanup</dt>
81
+ <dd><%= @status.cleanup[:enabled] ? "Enabled" : "Disabled (archiving_enabled = false)" %></dd>
82
+ <dt>Retention period</dt>
83
+ <dd><%= @status.cleanup[:retention_label] %></dd>
84
+ <dt>Last hourly summary</dt>
85
+ <dd>
86
+ <% if @status.cleanup[:last_summary_at] %>
87
+ <%= human_readable_occurred_at(@status.cleanup[:last_summary_at]) %>
88
+ <span class="text-subtle">(<%= time_ago_in_words(@status.cleanup[:last_summary_at]) %>)</span>
89
+ <% else %>
90
+ Never — cleanup will not delete unsummarised requests
91
+ <% end %>
92
+ </dd>
93
+ <dt>Count caps</dt>
94
+ <dd>Configured in <code>max_table_records</code>. Tables without a key have no count limit.</dd>
95
+ </dl>
96
+ <% end %>
97
+
98
+ <%= render "rails_pulse/components/panel",
99
+ title: "Database",
100
+ help_heading: "What size means",
101
+ help_text: "On PostgreSQL and MySQL this is Pulse table size including indexes. On a dedicated SQLite database this is the file size. Shared SQLite cannot report a Pulse-only file size." do %>
102
+ <dl class="descriptive-list mbs-4">
103
+ <dt>Adapter</dt>
104
+ <dd><%= @status.database[:adapter] %></dd>
105
+ <dt>Setup</dt>
106
+ <dd><%= @status.database[:setup_label] %></dd>
107
+ <dt>Database</dt>
108
+ <dd><code><%= @status.database[:database_name].presence || "—" %></code></dd>
109
+ <dt>Size</dt>
110
+ <dd>
111
+ <% if @status.overview[:display_bytes] %>
112
+ <%= human_readable_bytes(@status.overview[:display_bytes]) %>
113
+ <% else %>
114
+ Not available
115
+ <% end %>
116
+ </dd>
117
+ <% if @status.database[:size_note] %>
118
+ <dt>Note</dt>
119
+ <dd class="text-subtle"><%= @status.database[:size_note] %></dd>
120
+ <% end %>
121
+ </dl>
122
+ <% end %>
123
+ </div>
data/config/routes.rb CHANGED
@@ -9,12 +9,18 @@ RailsPulse::Engine.routes.draw do
9
9
  end
10
10
  end
11
11
  resources :operations, only: %i[show]
12
+ if RailsPulse.configuration.track_exceptions
13
+ resources :exceptions, only: %i[index show update] do
14
+ resources :occurrences, only: %i[show], controller: "exception_occurrences"
15
+ end
16
+ end
12
17
 
13
18
  if RailsPulse.configuration.track_jobs
14
19
  resources :jobs, only: %i[index show], param: :id do
15
20
  resources :runs, only: %i[index show], controller: "job_runs"
16
21
  end
17
22
  end
23
+ resource :storage, only: :show, controller: "storage"
18
24
  patch "pagination/limit", to: "application#set_pagination_limit", as: :pagination_limit
19
25
  patch "settings/global_filters", to: "application#set_global_filters", as: :settings_global_filters
20
26
  patch "settings/time_range", to: "application#set_time_range", as: :settings_time_range
@@ -23,15 +29,18 @@ RailsPulse::Engine.routes.draw do
23
29
  post "tags/:taggable_type/:taggable_id/add", to: "tags#create", as: :add_tag
24
30
  delete "tags/:taggable_type/:taggable_id/remove", to: "tags#destroy", as: :remove_tag
25
31
 
26
- # Deployment event recording (API endpoint for CI/CD)
27
- resources :deployments, only: [ :create ] do
32
+ # Deployments are both an API endpoint for CI/CD and a browsable record of
33
+ # what shipped when.
34
+ resources :deployments, only: %i[index show create] do
28
35
  collection do
29
36
  put :finish
30
37
  end
31
38
  end
32
39
 
33
- # CSP compliance testing
34
- get "csp_test", to: "csp_test#show", as: :csp_test
40
+ # CSP compliance testing (development/test only)
41
+ if Rails.env.local?
42
+ get "csp_test", to: "csp_test#show", as: :csp_test
43
+ end
35
44
 
36
45
  # Asset serving fallback
37
46
  get "rails-pulse-assets/:asset_name", to: "assets#show", as: :asset, constraints: { asset_name: /.*/ }
@@ -0,0 +1,50 @@
1
+ class CreateRailsPulseExceptions < ActiveRecord::Migration[7.0]
2
+ def up
3
+ unless table_exists?(:rails_pulse_exception_groups)
4
+ create_table :rails_pulse_exception_groups do |t|
5
+ t.string :fingerprint, null: false, comment: "SHA256 of exception_class + relative first app-code location"
6
+ t.string :exception_class, null: false, comment: "e.g. ActiveRecord::RecordNotFound"
7
+ t.string :location, comment: "Relative first app-code frame, e.g. app/models/user.rb#save"
8
+ t.text :message, comment: "Message from the most recent occurrence"
9
+ t.datetime :first_seen_at, null: false
10
+ t.datetime :last_seen_at, null: false
11
+ t.integer :occurrence_count, null: false, default: 0
12
+ t.string :status, null: false, default: "open", comment: "open, resolved, ignored"
13
+ t.datetime :resolved_at, comment: "When the group was last resolved"
14
+ t.boolean :preserve, null: false, default: false, comment: "Exempt from all automatic cleanup including occurrence rows"
15
+ t.timestamps
16
+ end
17
+
18
+ add_index :rails_pulse_exception_groups, :fingerprint, unique: true, name: "index_rp_exception_groups_on_fingerprint"
19
+ add_index :rails_pulse_exception_groups, :last_seen_at, name: "index_rp_exception_groups_on_last_seen_at"
20
+ add_index :rails_pulse_exception_groups, :exception_class, name: "index_rp_exception_groups_on_class"
21
+ add_index :rails_pulse_exception_groups, :status, name: "index_rp_exception_groups_on_status"
22
+ end
23
+
24
+ unless table_exists?(:rails_pulse_exception_occurrences)
25
+ create_table :rails_pulse_exception_occurrences do |t|
26
+ t.references :exception_group, null: false, index: false,
27
+ foreign_key: { to_table: :rails_pulse_exception_groups },
28
+ comment: "FK to the group this occurrence belongs to"
29
+ t.string :exception_class, null: false
30
+ t.text :message
31
+ t.text :backtrace, comment: "JSON array of {file, line, method} frames (first 50)"
32
+ t.string :request_url, comment: "Nullable — web requests only"
33
+ t.string :request_method, comment: "GET, POST, etc."
34
+ t.string :environment, comment: "production, staging, etc."
35
+ t.string :deploy_sha, comment: "Captured now even though Pro uses it — cannot backfill later"
36
+ t.text :request_params, comment: "JSON hash of filtered request params — web requests only"
37
+ t.datetime :occurred_at, null: false
38
+ t.timestamps
39
+ end
40
+
41
+ add_index :rails_pulse_exception_occurrences, :occurred_at, name: "index_rp_exception_occurrences_on_occurred_at"
42
+ add_index :rails_pulse_exception_occurrences, :exception_group_id, name: "index_rp_exception_occurrences_on_group_id"
43
+ end
44
+ end
45
+
46
+ def down
47
+ drop_table :rails_pulse_exception_occurrences if table_exists?(:rails_pulse_exception_occurrences)
48
+ drop_table :rails_pulse_exception_groups if table_exists?(:rails_pulse_exception_groups)
49
+ end
50
+ end
@@ -11,11 +11,16 @@ class AddActualQueryToOperations < ActiveRecord::Migration[7.0]
11
11
  # NOTE: On MySQL installations that hit the 255-char truncation bug,
12
12
  # actual_sql will contain a partial SQL string — the full query is unrecoverable.
13
13
  # The associated query.normalized_sql is unaffected and analysis still works.
14
- RailsPulse::Operation
15
- .where(operation_type: "sql", actual_sql: nil)
16
- .in_batches(of: 1000) do |batch|
17
- batch.update_all("actual_sql = label")
18
- end
14
+ #
15
+ # Uses execute() rather than the model so the backfill runs on the migration's own
16
+ # connection and sees the column added above, even on separate-database setups where
17
+ # RailsPulse::ApplicationRecord uses a different connection pool (SQLite keeps DDL
18
+ # inside the open transaction, so a second pool cannot see the new column).
19
+ execute(<<~SQL)
20
+ UPDATE rails_pulse_operations
21
+ SET actual_sql = label
22
+ WHERE operation_type = 'sql' AND actual_sql IS NULL
23
+ SQL
19
24
  end
20
25
 
21
26
  def down
@@ -0,0 +1,8 @@
1
+ class AddControllerActionToRailsPulseRoutes < ActiveRecord::Migration[7.0]
2
+ def change
3
+ unless column_exists?(:rails_pulse_routes, :controller_action)
4
+ add_column :rails_pulse_routes, :controller_action, :string,
5
+ comment: "Rails controller and action handling this route (e.g., admin/users#show)"
6
+ end
7
+ end
8
+ end