rails_pulse 0.3.3 → 0.4.0.pre.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (167) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +272 -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/templates/db/rails_pulse_schema.rb +54 -6
  127. data/lib/generators/rails_pulse/templates/rails_pulse.rb +152 -16
  128. data/lib/generators/rails_pulse/upgrade_generator.rb +166 -38
  129. data/lib/rails_pulse/cleanup_service.rb +110 -15
  130. data/lib/rails_pulse/configuration.rb +125 -6
  131. data/lib/rails_pulse/engine.rb +32 -17
  132. data/lib/rails_pulse/installers/config_updater.rb +202 -0
  133. data/lib/rails_pulse/job_run_collector.rb +43 -16
  134. data/lib/rails_pulse/middleware/asset_server.rb +11 -0
  135. data/lib/rails_pulse/middleware/request_collector.rb +9 -4
  136. data/lib/rails_pulse/packaged_assets.rb +147 -0
  137. data/lib/rails_pulse/route_indexes.rb +66 -0
  138. data/lib/rails_pulse/subscribers/exception_subscriber.rb +36 -0
  139. data/lib/rails_pulse/tracker.rb +12 -9
  140. data/lib/rails_pulse/version.rb +1 -1
  141. data/lib/rails_pulse.rb +1 -0
  142. data/lib/rails_pulse_server.ru +16 -47
  143. data/lib/tasks/rails_pulse.rake +83 -4
  144. data/lib/tasks/rails_pulse_assets.rake +15 -0
  145. data/lib/tasks/rails_pulse_tasks.rake +29 -0
  146. data/public/rails-pulse-assets/rails-pulse-icons.js +6 -2
  147. data/public/rails-pulse-assets/rails-pulse.css +1 -1
  148. data/public/rails-pulse-assets/rails-pulse.js +18 -117
  149. metadata +78 -37
  150. data/Rakefile +0 -554
  151. data/app/javascript/rails_pulse/controllers/form_controller.js +0 -39
  152. data/app/javascript/rails_pulse/controllers/period_selector_controller.js +0 -19
  153. data/app/javascript/rails_pulse/controllers/timezone_controller.js +0 -89
  154. data/app/javascript/rails_pulse/theme.js +0 -416
  155. data/config/brakeman.ignore +0 -190
  156. data/config/brakeman.yml +0 -68
  157. data/config/initializers/rails_pulse.rb +0 -287
  158. data/public/rails-pulse-assets/csp-test.js +0 -120
  159. data/public/rails-pulse-assets/rails-pulse-icons.js.map +0 -13
  160. data/public/rails-pulse-assets/rails-pulse.css.map +0 -1
  161. data/public/rails-pulse-assets/rails-pulse.js.map +0 -7
  162. data/vendor/assets/javascripts/rails-pulse-icons.js +0 -93
  163. data/vendor/assets/javascripts/rails-pulse-icons.js.map +0 -13
  164. data/vendor/assets/javascripts/rails-pulse.js +0 -157
  165. data/vendor/assets/javascripts/rails-pulse.js.map +0 -7
  166. data/vendor/assets/stylesheets/rails-pulse.css +0 -1
  167. data/vendor/assets/stylesheets/rails-pulse.css.map +0 -1
@@ -109,7 +109,7 @@ module RailsPulse
109
109
 
110
110
  # If filtering or sorting by route_path, we need to join the routes table
111
111
  needs_join = @ransack_query.sorts.any? { |sort| sort.name == "route_path" } ||
112
- params.dig(:q, :route_path_cont).present?
112
+ ransack_params[:route_path_cont].present?
113
113
 
114
114
  if needs_join
115
115
  base_query = base_query.joins(:route)
@@ -54,7 +54,6 @@ module RailsPulse
54
54
  show_action? ? { route: @route } : {}
55
55
  end
56
56
 
57
- # Filter to scope table results to a specific route on show pages
58
57
  def show_resource_filter
59
58
  { route_id_eq: @route.id }
60
59
  end
@@ -92,6 +91,8 @@ module RailsPulse
92
91
 
93
92
  # For PostgreSQL compatibility with DISTINCT + ORDER BY
94
93
  # we need to include computed columns in SELECT when ordering by them
94
+ base_query = base_query.joins(:route).preload(:route)
95
+
95
96
  if ordering_by_computed_column?
96
97
  base_query.select("rails_pulse_requests.*, #{status_indicator_sql} as status_indicator_value").distinct
97
98
  else
@@ -109,6 +110,11 @@ module RailsPulse
109
110
  end
110
111
  end
111
112
 
113
+ # Request/route-name filters that Summary chart queries don't join for.
114
+ def chart_filter_exclusions
115
+ %w[route_path_cont route_controller_action_cont controller_action_cont http_method_eq]
116
+ end
117
+
112
118
  def default_time_range_key
113
119
  :last_14_days
114
120
  end
@@ -0,0 +1,7 @@
1
+ module RailsPulse
2
+ class StorageController < ApplicationController
3
+ def show
4
+ @status = RailsPulse::Dashboard::StorageStatus.new
5
+ end
6
+ end
7
+ end
@@ -19,7 +19,7 @@ module RailsPulse
19
19
  flash[:alert] = "Failed to add tag"
20
20
  end
21
21
 
22
- redirect_back(fallback_location: root_path)
22
+ redirect_back(fallback_location: root_path, allow_other_host: false)
23
23
  end
24
24
 
25
25
  def destroy
@@ -30,7 +30,7 @@ module RailsPulse
30
30
  flash[:alert] = "Failed to remove tag"
31
31
  end
32
32
 
33
- redirect_back(fallback_location: root_path)
33
+ redirect_back(fallback_location: root_path, allow_other_host: false)
34
34
  end
35
35
 
36
36
  private
@@ -1,5 +1,6 @@
1
1
  module RailsPulse
2
2
  module ApplicationHelper
3
+ include BacktraceHelper
3
4
  include BreadcrumbsHelper
4
5
  include ChartHelper
5
6
  include CspHelper
@@ -0,0 +1,85 @@
1
+ module RailsPulse
2
+ module BacktraceHelper
3
+ APP_FRAME_PATTERN = %r{/app/|/config/|/lib/}
4
+ GEM_FRAME_PATTERN = %r{/gems/|/rubygems/|/bundler/|/lib/ruby/}
5
+
6
+ def app_frame?(frame)
7
+ file = frame["file"].to_s
8
+ file.match?(APP_FRAME_PATTERN) && !file.match?(GEM_FRAME_PATTERN)
9
+ end
10
+
11
+ # Strip everything before /app/, /lib/, /config/ so paths are relative.
12
+ # Falls back to gem-name/rest for gem frames, or just basename for stdlib.
13
+ def frame_display_path(frame)
14
+ file = frame["file"].to_s
15
+ # Gem frames: /…/gems/[ruby-ver]/gems/[gem-name]/lib/… → gem-name/lib/…
16
+ if (match = file.match(%r{/gems/[^/]+/gems/([^/]+)/(.+)}))
17
+ "#{match[1]}/#{match[2]}"
18
+ # Bundler path gems: /…/gems/[gem-name]/lib/… → gem-name/lib/…
19
+ elsif (match = file.match(%r{/gems/([^/]+)/(.+)}))
20
+ "#{match[1]}/#{match[2]}"
21
+ # App frames: strip absolute prefix, keep /app/…, /lib/…, /config/…
22
+ elsif (match = file.match(%r{(/(?:app|lib|config)/.+)}))
23
+ match[1]
24
+ else
25
+ File.basename(file)
26
+ end
27
+ end
28
+
29
+ def frame_dirname(frame)
30
+ File.dirname(frame_display_path(frame)) + "/"
31
+ end
32
+
33
+ def frame_basename(frame)
34
+ File.basename(frame_display_path(frame))
35
+ end
36
+
37
+ # Directories (relative to Rails.root) whose source may be shown, and the
38
+ # file types within them. Everything else under Rails.root — initializers
39
+ # with inline keys, credentials, database.yml, .env, tmp/ — stays
40
+ # unreadable even when a backtrace frame points at it.
41
+ SOURCE_ALLOWED_DIRECTORIES = %w[app/ lib/].freeze
42
+ SOURCE_ALLOWED_FILES = %w[config/routes.rb].freeze
43
+ SOURCE_ALLOWED_EXTENSIONS = %w[.rb .erb .rake .haml .slim .jbuilder].freeze
44
+
45
+ def frame_source_lines(frame, radius: 3)
46
+ file = frame["file"].to_s
47
+ line = frame["line"].to_i
48
+ return nil if file.blank? || line < 1
49
+ return nil unless File.exist?(file) && File.file?(file)
50
+ return nil unless source_readable?(file)
51
+
52
+ first_line = [ line - radius, 1 ].max
53
+ last_line = line + radius
54
+
55
+ lines = {}
56
+ File.foreach(file).with_index(1) do |content, lineno|
57
+ break if lineno > last_line
58
+ lines[lineno] = content.rstrip if lineno >= first_line
59
+ end
60
+ lines
61
+ rescue Errno::EACCES, Errno::ENOENT
62
+ nil
63
+ end
64
+
65
+ private
66
+
67
+ # Only read source for code files under Rails.root's app/ and lib/ (plus
68
+ # config/routes.rb). Resolved through realpath so symlinks cannot point
69
+ # outside, and matched on the relative path so a directory merely named
70
+ # "app" elsewhere on disk does not qualify.
71
+ def source_readable?(file)
72
+ real = File.realpath(file)
73
+ root = "#{Rails.root.realpath}#{File::SEPARATOR}"
74
+ return false unless real.start_with?(root)
75
+
76
+ relative = real.delete_prefix(root)
77
+ return false unless SOURCE_ALLOWED_EXTENSIONS.include?(File.extname(relative))
78
+
79
+ SOURCE_ALLOWED_FILES.include?(relative) ||
80
+ SOURCE_ALLOWED_DIRECTORIES.any? { |dir| relative.start_with?(dir) }
81
+ rescue Errno::ENOENT
82
+ false
83
+ end
84
+ end
85
+ end
@@ -3,7 +3,9 @@ module RailsPulse
3
3
  # Maps singularized path segments to their actual model class names
4
4
  # when the default classify convention doesn't match
5
5
  SEGMENT_CLASS_OVERRIDES = {
6
- "run" => "JobRun"
6
+ "run" => "JobRun",
7
+ "exception" => "ExceptionGroup",
8
+ "occurrence" => "ExceptionOccurrence"
7
9
  }.freeze
8
10
 
9
11
  def breadcrumbs
@@ -88,6 +90,7 @@ module RailsPulse
88
90
 
89
91
  crumbs
90
92
  end
93
+
91
94
  def page_title
92
95
  crumbs = breadcrumbs
93
96
  return "Rails Pulse" if crumbs.empty?
@@ -43,9 +43,6 @@ module RailsPulse
43
43
  axisPointer: { type: "shadow" },
44
44
  formatter: tooltip_formatter
45
45
  },
46
- toolbox: {
47
- feature: { saveAsImage: { show: false } }
48
- },
49
46
  xAxis: {
50
47
  axisLine: { show: false },
51
48
  axisTick: { show: false },
@@ -25,24 +25,16 @@ module RailsPulse
25
25
  RailsPulse::Engine.routes.url_helpers.respond_to?(method, include_private) || super
26
26
  end
27
27
 
28
- # Generate asset paths using Rails asset pipeline
29
- # This allows assets to work with CDN, digests, and precompiled manifests
28
+ # After `assets:precompile`, files live in public/assets with a digest
29
+ # so config.asset_host / CDN-only CSP work. Until then (development,
30
+ # or hosts with no pipeline) serve from the gem via AssetServer.
31
+ # Always pass the result to tag.link / tag.script, not stylesheet_link_tag:
32
+ # a middleware path must not be rewritten onto the CDN.
30
33
  def asset_path(asset_name)
31
- # Only use asset pipeline if we have Sprockets/Propshaft configured
32
- # Otherwise fall back to middleware serving
33
- if defined?(::Sprockets) || defined?(::Propshaft)
34
- # Use the main application's asset_path helper to ensure we get
35
- # the correct digested paths from the precompiled manifest
36
- begin
37
- ActionController::Base.helpers.asset_path(asset_name)
38
- rescue => e
39
- # Fallback to direct path if asset pipeline is not available
40
- Rails.logger.warn "[Rails Pulse] Asset pipeline error for #{asset_name}: #{e.message}"
41
- "/rails-pulse-assets/#{asset_name}"
42
- end
34
+ if (packaged = RailsPulse::PackagedAssets.url_path(asset_name))
35
+ ActionController::Base.helpers.asset_path(packaged)
43
36
  else
44
- # No asset pipeline - use middleware serving
45
- "/rails-pulse-assets/#{asset_name}"
37
+ "/rails-pulse-assets/#{RailsPulse::VERSION}/#{asset_name}"
46
38
  end
47
39
  end
48
40
  end
@@ -1,24 +1,48 @@
1
- import * as echarts from "echarts";
2
- import "./theme";
1
+ // Only the chart types and components Rails Pulse actually renders are pulled in.
2
+ // Importing the "echarts" barrel instead would bundle every chart type (sankey,
3
+ // gauge, map, ...) and roughly quintuple the bundle.
4
+ import * as echarts from "echarts/core";
5
+ import { BarChart, LineChart } from "echarts/charts";
6
+ import {
7
+ DataZoomComponent,
8
+ GridComponent,
9
+ LegendComponent,
10
+ MarkAreaComponent,
11
+ MarkLineComponent,
12
+ TooltipComponent
13
+ } from "echarts/components";
14
+ import { CanvasRenderer } from "echarts/renderers";
3
15
  import { Application } from "@hotwired/stimulus";
4
16
 
17
+ // TooltipComponent installs AxisPointer itself, so `axisPointer` options work.
18
+ echarts.use([
19
+ BarChart,
20
+ LineChart,
21
+ DataZoomComponent,
22
+ GridComponent,
23
+ LegendComponent,
24
+ MarkAreaComponent,
25
+ MarkLineComponent,
26
+ TooltipComponent,
27
+ CanvasRenderer
28
+ ]);
29
+
5
30
  // CSS Zero Controllers
6
31
  import ContextMenuController from "./controllers/context_menu_controller";
7
32
  import DatePickerController from "./controllers/datepicker_controller";
8
33
  import DialogController from "./controllers/dialog_controller";
9
34
  import MenuController from "./controllers/menu_controller";
10
35
  import PopoverController from "./controllers/popover_controller";
11
- import FormController from "./controllers/form_controller";
12
36
 
13
37
  // Rails Pulse Controllers
14
38
  import ChartController from "./controllers/chart_controller";
15
39
  import IndexController from "./controllers/index_controller";
16
40
  import ColorSchemeController from "./controllers/color_scheme_controller";
17
41
  import PaginationController from "./controllers/pagination_controller";
18
- import TimezoneController from "./controllers/timezone_controller";
19
42
  import IconController from "./controllers/icon_controller";
20
43
  import ExpandableRowsController from "./controllers/expandable_rows_controller";
21
44
  import CollapsibleController from "./controllers/collapsible_controller";
45
+ import ClipboardController from "./controllers/clipboard_controller";
22
46
  import TableSortController from "./controllers/table_sort_controller";
23
47
  import GlobalFiltersController from "./controllers/global_filters_controller";
24
48
  import CustomRangeController from "./controllers/custom_range_controller"
@@ -26,7 +50,6 @@ import SeriesToggleController from "./controllers/series_toggle_controller";
26
50
  import DeploymentMarkersToggleController from "./controllers/deployment_markers_toggle_controller";
27
51
  import FlameGraphController from "./controllers/flame_graph_controller";
28
52
  import ChartSwitcherController from "./controllers/chart_switcher_controller";
29
- import PeriodSelectorController from "./controllers/period_selector_controller";
30
53
  import TimeRangeController from "./controllers/time_range_controller";
31
54
  const application = Application.start();
32
55
 
@@ -42,16 +65,15 @@ application.register("rails-pulse--datepicker", DatePickerController);
42
65
  application.register("rails-pulse--dialog", DialogController);
43
66
  application.register("rails-pulse--menu", MenuController);
44
67
  application.register("rails-pulse--popover", PopoverController);
45
- application.register("rails-pulse--form", FormController);
46
68
 
47
69
  application.register("rails-pulse--chart", ChartController);
48
70
  application.register("rails-pulse--index", IndexController);
49
71
  application.register("rails-pulse--color-scheme", ColorSchemeController);
50
72
  application.register("rails-pulse--pagination", PaginationController);
51
- application.register("rails-pulse--timezone", TimezoneController);
52
73
  application.register("rails-pulse--icon", IconController);
53
74
  application.register("rails-pulse--expandable-rows", ExpandableRowsController);
54
75
  application.register("rails-pulse--collapsible", CollapsibleController);
76
+ application.register("rails-pulse--clipboard", ClipboardController);
55
77
  application.register("rails-pulse--table-sort", TableSortController);
56
78
  application.register("rails-pulse--global-filters", GlobalFiltersController);
57
79
  application.register("rails-pulse--custom-range", CustomRangeController);
@@ -59,7 +81,6 @@ application.register("rails-pulse--series-toggle", SeriesToggleController);
59
81
  application.register("rails-pulse--deployment-markers-toggle", DeploymentMarkersToggleController);
60
82
  application.register("rails-pulse--flame-graph", FlameGraphController);
61
83
  application.register("rails-pulse--chart-switcher", ChartSwitcherController);
62
- application.register("rails-pulse--period-selector", PeriodSelectorController);
63
84
  application.register("rails-pulse--time-range", TimeRangeController);
64
85
 
65
86
  // Register ECharts theme for Rails Pulse
@@ -0,0 +1,58 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["source"]
5
+ static values = {
6
+ label: { type: String, default: "copy" },
7
+ copiedLabel: { type: String, default: "copied!" },
8
+ errorLabel: { type: String, default: "copy failed" },
9
+ resetDelay: { type: Number, default: 1500 }
10
+ }
11
+
12
+ disconnect() {
13
+ if (this.resetTimeout) clearTimeout(this.resetTimeout)
14
+ }
15
+
16
+ async copy(event) {
17
+ const button = event.currentTarget
18
+ const text = this.hasSourceTarget ? this.sourceTarget.textContent : ""
19
+
20
+ try {
21
+ await this.write(text)
22
+ this.flash(button, this.copiedLabelValue)
23
+ } catch {
24
+ this.flash(button, this.errorLabelValue)
25
+ }
26
+ }
27
+
28
+ // navigator.clipboard is undefined on insecure origins, which is where a
29
+ // mounted dashboard often lives (http://staging.internal). Fall back to the
30
+ // legacy execCommand path there rather than silently doing nothing.
31
+ async write(text) {
32
+ if (navigator.clipboard && window.isSecureContext) {
33
+ return navigator.clipboard.writeText(text)
34
+ }
35
+
36
+ const textarea = document.createElement("textarea")
37
+ textarea.value = text
38
+ textarea.setAttribute("readonly", "")
39
+ textarea.style.position = "fixed"
40
+ textarea.style.opacity = "0"
41
+ document.body.appendChild(textarea)
42
+ textarea.select()
43
+
44
+ try {
45
+ if (!document.execCommand("copy")) throw new Error("execCommand copy failed")
46
+ } finally {
47
+ document.body.removeChild(textarea)
48
+ }
49
+ }
50
+
51
+ flash(button, message) {
52
+ button.textContent = message
53
+ if (this.resetTimeout) clearTimeout(this.resetTimeout)
54
+ this.resetTimeout = setTimeout(() => {
55
+ button.textContent = this.labelValue
56
+ }, this.resetDelayValue)
57
+ }
58
+ }
@@ -40,6 +40,16 @@ export default class extends Controller {
40
40
  this.handleChartInitialized = this.onChartInitialized.bind(this);
41
41
 
42
42
  document.addEventListener('stimulus:echarts:rendered', this.handleChartInitialized);
43
+
44
+ // Releasing the mouse outside the chart still ends a drag-zoom. This is not
45
+ // chart-specific, so it is bound once per controller rather than in
46
+ // setupChartEventListeners — that runs again on every chart switch and used
47
+ // to stack a new document listener each time.
48
+ this.handleDocumentMouseUp = () => {
49
+ if (!this.chart || !this.visibleData) return;
50
+ this.handleZoomChange();
51
+ };
52
+ document.addEventListener('mouseup', this.handleDocumentMouseUp);
43
53
  }
44
54
 
45
55
  disconnect() {
@@ -226,12 +236,6 @@ export default class extends Controller {
226
236
  this.handleZoomChange();
227
237
  });
228
238
 
229
- // When releasing the mouse button outside the chart, we want to check if the visible data has changed
230
- this.handleDocumentMouseUp = () => {
231
- this.handleZoomChange();
232
- };
233
- document.addEventListener('mouseup', this.handleDocumentMouseUp);
234
-
235
239
  // Use ZRender (the underlying canvas renderer) to catch clicks anywhere in the
236
240
  // chart grid — not just on data point markers — then map the pixel position back
237
241
  // to the nearest x-axis bucket so any click in a column triggers filtering.
@@ -398,8 +402,8 @@ export default class extends Controller {
398
402
  }
399
403
 
400
404
  if (this.getAxisType(currentOption) === 'time') {
401
- const startValue = dataZoom.startValue || xAxisData[0];
402
- const endValue = dataZoom.endValue || xAxisData[xAxisData.length - 1];
405
+ const startValue = dataZoom.startValue ?? xAxisData[0];
406
+ const endValue = dataZoom.endValue ?? xAxisData[xAxisData.length - 1];
403
407
 
404
408
  return {
405
409
  xAxis: xAxisData.filter(value => value >= startValue && value <= endValue),
@@ -410,8 +414,10 @@ export default class extends Controller {
410
414
  };
411
415
  }
412
416
 
413
- const startValue = dataZoom.startValue || 0;
414
- const endValue = dataZoom.endValue || xAxisData.length - 1;
417
+ // ?? not ||: index 0 is a valid category position, and `0 || fallback`
418
+ // silently widens the range back to the full axis.
419
+ const startValue = dataZoom.startValue ?? 0;
420
+ const endValue = dataZoom.endValue ?? xAxisData.length - 1;
415
421
 
416
422
  return {
417
423
  xAxis: xAxisData.slice(startValue, endValue + 1),
@@ -28,11 +28,15 @@ export default class extends Controller {
28
28
 
29
29
  // Restore the pagination limit from URL or session storage on page load
30
30
  restorePaginationLimit() {
31
+ // hasLimitTarget, not truthiness: accessing a missing Stimulus target throws
32
+ // rather than returning undefined, which would break connect() outright.
33
+ if (!this.hasLimitTarget) return
34
+
31
35
  // URL params take precedence over session storage
32
36
  const urlParams = new URLSearchParams(window.location.search)
33
37
  const urlLimit = urlParams.get('limit')
34
38
 
35
- if (urlLimit && this.limitTarget) {
39
+ if (urlLimit) {
36
40
  // Sync sessionStorage with URL param
37
41
  sessionStorage.setItem(this.storageKeyValue, urlLimit)
38
42
  if (this.limitTarget.value !== urlLimit) {
@@ -41,7 +45,7 @@ export default class extends Controller {
41
45
  } else {
42
46
  // Fall back to sessionStorage if no URL param
43
47
  const savedLimit = sessionStorage.getItem(this.storageKeyValue)
44
- if (savedLimit && this.limitTarget && this.limitTarget.value !== savedLimit) {
48
+ if (savedLimit && this.limitTarget.value !== savedLimit) {
45
49
  this.limitTarget.value = savedLimit
46
50
  }
47
51
  }
@@ -42,6 +42,10 @@ export default class extends Controller {
42
42
  }
43
43
 
44
44
  disconnect() {
45
+ // A pending debouncedShow would otherwise fire showPopover() on an element
46
+ // that has already been removed (e.g. the table was replaced by a fetch).
47
+ clearTimeout(this.#showTimer)
48
+ clearTimeout(this.#hideTimer)
45
49
  this.cleanup()
46
50
  }
47
51
 
@@ -5,20 +5,25 @@ module RailsPulse
5
5
  # Tag validation constants
6
6
  TAG_NAME_REGEX = /\A[a-z0-9_-]+\z/i
7
7
  MAX_TAG_LENGTH = 50
8
+ # Escape character for LIKE patterns over the serialized tags column.
9
+ # Never appears in a valid tag name, and needs no quoting on any adapter.
10
+ LIKE_ESCAPE = "!".freeze
8
11
 
9
12
  included do
10
13
  # Callbacks
11
14
  before_save :ensure_tags_is_array
12
15
 
13
- # Scopes with table name qualification to avoid ambiguity
14
- # Note: LIKE patterns are sanitized to prevent SQL injection via wildcards
16
+ # Scopes with table name qualification to avoid ambiguity.
17
+ # LIKE patterns are sanitized so `_`/`%` in a tag are matched literally.
18
+ # The ESCAPE clause is explicit because SQLite has no default escape
19
+ # character — without it `my\_tag` matches a literal backslash there
20
+ # and the filter silently matches nothing. `!` is used rather than `\`
21
+ # because MySQL and PostgreSQL disagree on how to quote a backslash.
15
22
  scope :with_tag, ->(tag) {
16
- sanitized_tag = sanitize_sql_like(tag.to_s, "\\")
17
- where("#{table_name}.tags LIKE ?", "%\"#{sanitized_tag}\"%")
23
+ where("#{table_name}.tags LIKE ? ESCAPE '!'", "%\"#{sanitize_sql_like(tag.to_s, LIKE_ESCAPE)}\"%")
18
24
  }
19
25
  scope :without_tag, ->(tag) {
20
- sanitized_tag = sanitize_sql_like(tag.to_s, "\\")
21
- where.not("#{table_name}.tags LIKE ?", "%\"#{sanitized_tag}\"%")
26
+ where.not("#{table_name}.tags LIKE ? ESCAPE '!'", "%\"#{sanitize_sql_like(tag.to_s, LIKE_ESCAPE)}\"%")
22
27
  }
23
28
  scope :with_tags, -> { where("#{table_name}.tags IS NOT NULL AND #{table_name}.tags != '[]'") }
24
29
  end
@@ -173,6 +173,59 @@ module RailsPulse
173
173
  [ icon, format_percentage(percentage.abs, precision) ]
174
174
  end
175
175
 
176
+ # Compares a subject against its own history rather than against the
177
+ # immediately preceding window.
178
+ #
179
+ # The period-over-period arrow above answers "is this week worse than last
180
+ # week?", which is noisy — last week may itself have been unusual. This
181
+ # answers "is this worse than how this normally behaves?", which is the
182
+ # question the number is actually being read for.
183
+ #
184
+ # Only available where there is a single subject to have a history. Index
185
+ # pages aggregate across every route or query, so they keep the
186
+ # period-over-period arrow.
187
+ #
188
+ # @return [Array(String, String, String), nil] icon, amount, caption
189
+ def baseline_trend_for(subject, metric:)
190
+ return nil if subject.nil?
191
+
192
+ comparison = RailsPulse::Operations::Compare.call(subject, metric: metric)
193
+ return nil unless comparison.sufficient_data?
194
+
195
+ percentage = comparison.percent_change
196
+
197
+ icon = if percentage.abs < 0.1
198
+ "move-right"
199
+ elsif percentage.positive?
200
+ "trending-up"
201
+ else
202
+ "trending-down"
203
+ end
204
+
205
+ [ icon, format_percentage(percentage.abs, 1), baseline_caption(comparison) ]
206
+ end
207
+
208
+ # Names the change point only when there is a regression to explain. On a
209
+ # healthy metric "since Aug 26" would be noise, and locating it costs a
210
+ # second query that is not worth spending to say nothing.
211
+ def baseline_caption(comparison)
212
+ caption = "Compared to its #{baseline_window_text} normal"
213
+ return caption unless comparison.regression?
214
+
215
+ change_point = RailsPulse::Operations::ChangePoint.call(
216
+ comparison.subject, metric: comparison.metric
217
+ )
218
+ return caption if change_point.nil?
219
+
220
+ stamp = change_point.hourly? ? change_point.at.strftime("%b %-d %H:%M") : change_point.at.strftime("%b %-d")
221
+ "#{caption} · since #{stamp}"
222
+ end
223
+
224
+ def baseline_window_text
225
+ days = (RailsPulse.configuration.baseline_window / 1.day).round
226
+ "#{days}-day"
227
+ end
228
+
176
229
  # Existing format helpers (keep as-is)
177
230
  def format_percentage(value, precision = 1)
178
231
  "#{value.round(precision)}%"
@@ -11,6 +11,7 @@ module RailsPulse
11
11
  @route_thresholds = RailsPulse.configuration.route_thresholds
12
12
  @query_thresholds = RailsPulse.configuration.query_thresholds
13
13
  @job_thresholds = RailsPulse.configuration.job_thresholds
14
+ @exception_thresholds = RailsPulse.configuration.exception_thresholds
14
15
  end
15
16
 
16
17
  def to_health_data
@@ -18,6 +19,7 @@ module RailsPulse
18
19
  routes: route_counts,
19
20
  queries: query_counts,
20
21
  jobs: job_counts,
22
+ exceptions: exception_counts,
21
23
  storage: storage_counts
22
24
  }
23
25
  end
@@ -108,6 +110,56 @@ module RailsPulse
108
110
 
109
111
  { healthy: healthy, slow: slow, critical: critical }
110
112
  end
113
+
114
+ # Exception groups classified by how often they fired over the dashboard
115
+ # period, read from summaries rather than from ExceptionGroup's lifetime
116
+ # occurrence_count — a group that fired ten thousand times last year and
117
+ # is now silent is not a critical problem today.
118
+ #
119
+ # A group with occurrences in the period but below the warning threshold
120
+ # counts as "slow" rather than "healthy": an exception happening at all is
121
+ # not healthy, it is just not yet urgent.
122
+ def exception_counts
123
+ return nil unless RailsPulse.configuration.track_exceptions
124
+ return nil unless exception_summaries_available?
125
+
126
+ start, finish = period_range
127
+
128
+ counts = RailsPulse::Summary
129
+ .for_exceptions
130
+ .where.not(summarizable_id: 0)
131
+ .where(period_start: start..finish)
132
+ .group(:summarizable_id)
133
+ .sum(:count)
134
+
135
+ healthy = slow = critical = 0
136
+
137
+ open_group_ids.each do |group_id|
138
+ occurrences = counts[group_id].to_i
139
+
140
+ if occurrences >= @exception_thresholds[:critical]
141
+ critical += 1
142
+ elsif occurrences >= @exception_thresholds[:warning]
143
+ slow += 1
144
+ elsif occurrences.positive?
145
+ slow += 1
146
+ else
147
+ healthy += 1
148
+ end
149
+ end
150
+
151
+ { healthy: healthy, slow: slow, critical: critical }
152
+ end
153
+
154
+ def open_group_ids
155
+ RailsPulse::ExceptionGroup.where(status: "open").pluck(:id)
156
+ end
157
+
158
+ def exception_summaries_available?
159
+ RailsPulse::ExceptionGroup.table_exists?
160
+ rescue ActiveRecord::ActiveRecordError
161
+ false
162
+ end
111
163
  end
112
164
  end
113
165
  end