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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +272 -0
- data/README.md +44 -3
- data/app/assets/stylesheets/rails_pulse/application.css +21 -32
- data/app/assets/stylesheets/rails_pulse/components/backtrace.css +238 -0
- data/app/assets/stylesheets/rails_pulse/components/badge.css +32 -0
- data/app/assets/stylesheets/rails_pulse/components/button.css +2 -36
- data/app/assets/stylesheets/rails_pulse/components/card.css +8 -11
- data/app/assets/stylesheets/rails_pulse/components/chart.css +16 -3
- data/app/assets/stylesheets/rails_pulse/components/csp_safe_positioning.css +5 -39
- data/app/assets/stylesheets/rails_pulse/components/dashboard.css +0 -52
- data/app/assets/stylesheets/rails_pulse/components/deployments.css +39 -0
- data/app/assets/stylesheets/rails_pulse/components/dialog.css +0 -5
- data/app/assets/stylesheets/rails_pulse/components/input.css +0 -14
- data/app/assets/stylesheets/rails_pulse/components/layouts.css +0 -20
- data/app/assets/stylesheets/rails_pulse/components/menu.css +0 -16
- data/app/assets/stylesheets/rails_pulse/components/sidebar_menu.css +0 -16
- data/app/assets/stylesheets/rails_pulse/components/storage.css +187 -0
- data/app/assets/stylesheets/rails_pulse/components/tags.css +0 -14
- data/app/assets/stylesheets/rails_pulse/components/time_range_selector.css +5 -5
- data/app/assets/stylesheets/rails_pulse/components/utilities.css +0 -17
- data/app/controllers/concerns/chart_table_concern.rb +15 -3
- data/app/controllers/concerns/ransack_params_concern.rb +18 -0
- data/app/controllers/concerns/response_range_concern.rb +1 -1
- data/app/controllers/concerns/time_range_concern.rb +28 -14
- data/app/controllers/rails_pulse/application_controller.rb +104 -31
- data/app/controllers/rails_pulse/dashboard_controller.rb +9 -0
- data/app/controllers/rails_pulse/deployments_controller.rb +26 -5
- data/app/controllers/rails_pulse/exception_occurrences_controller.rb +19 -0
- data/app/controllers/rails_pulse/exceptions_controller.rb +119 -0
- data/app/controllers/rails_pulse/job_runs_controller.rb +1 -1
- data/app/controllers/rails_pulse/requests_controller.rb +1 -1
- data/app/controllers/rails_pulse/routes_controller.rb +7 -1
- data/app/controllers/rails_pulse/storage_controller.rb +7 -0
- data/app/controllers/rails_pulse/tags_controller.rb +2 -2
- data/app/helpers/rails_pulse/application_helper.rb +1 -0
- data/app/helpers/rails_pulse/backtrace_helper.rb +85 -0
- data/app/helpers/rails_pulse/breadcrumbs_helper.rb +4 -1
- data/app/helpers/rails_pulse/chart_helper.rb +0 -3
- data/app/helpers/rails_pulse/route_helper.rb +8 -16
- data/app/javascript/rails_pulse/application.js +29 -8
- data/app/javascript/rails_pulse/controllers/clipboard_controller.js +58 -0
- data/app/javascript/rails_pulse/controllers/index_controller.js +16 -10
- data/app/javascript/rails_pulse/controllers/pagination_controller.js +6 -2
- data/app/javascript/rails_pulse/controllers/popover_controller.js +4 -0
- data/app/models/concerns/rails_pulse/taggable.rb +11 -6
- data/app/models/rails_pulse/cards/base.rb +53 -0
- data/app/models/rails_pulse/dashboard/health_summary.rb +52 -0
- data/app/models/rails_pulse/dashboard/needs_attention.rb +73 -1
- data/app/models/rails_pulse/dashboard/storage_status.rb +490 -0
- data/app/models/rails_pulse/deployment.rb +47 -1
- data/app/models/rails_pulse/exception_group.rb +37 -0
- data/app/models/rails_pulse/exception_occurrence.rb +27 -0
- data/app/models/rails_pulse/exceptions/cards/exception_rate.rb +116 -0
- data/app/models/rails_pulse/exceptions/cards/open_groups.rb +49 -0
- data/app/models/rails_pulse/exceptions/cards/total_occurrences.rb +62 -0
- data/app/models/rails_pulse/exceptions/charts/occurrence_volume.rb +56 -0
- data/app/models/rails_pulse/exceptions/raw_data.rb +216 -0
- data/app/models/rails_pulse/jobs/cards/p95_duration.rb +8 -2
- data/app/models/rails_pulse/operation.rb +13 -4
- data/app/models/rails_pulse/queries/cards/percentile_query_times.rb +8 -3
- data/app/models/rails_pulse/request.rb +5 -1
- data/app/models/rails_pulse/route.rb +71 -21
- data/app/models/rails_pulse/routes/cards/error_rates.rb +6 -3
- data/app/models/rails_pulse/routes/cards/percentile_response_times.rb +8 -3
- data/app/models/rails_pulse/routes/tables/index.rb +8 -6
- data/app/models/rails_pulse/summary.rb +21 -10
- data/app/models/rails_pulse/tables/base.rb +4 -2
- data/app/services/rails_pulse/analysis/explain_plan_analyzer.rb +47 -12
- data/app/services/rails_pulse/exception_capture_service.rb +228 -0
- data/app/services/rails_pulse/exception_message_sanitizer.rb +152 -0
- data/app/services/rails_pulse/operations/change_point.rb +144 -0
- data/app/services/rails_pulse/operations/compare.rb +92 -0
- data/app/services/rails_pulse/operations/comparison.rb +126 -0
- data/app/services/rails_pulse/operations/metric.rb +63 -0
- data/app/services/rails_pulse/operations/series.rb +84 -0
- data/app/services/rails_pulse/operations/subject.rb +77 -0
- data/app/services/rails_pulse/route_controller_action_backfiller.rb +110 -0
- data/app/services/rails_pulse/route_merger.rb +99 -0
- data/app/services/rails_pulse/route_migrator.rb +139 -0
- data/app/services/rails_pulse/route_path_normalizer.rb +79 -0
- data/app/services/rails_pulse/route_recognizer.rb +47 -0
- data/app/services/rails_pulse/summary_service.rb +45 -0
- data/app/services/rails_pulse/tag_filter_service.rb +5 -3
- data/app/views/layouts/rails_pulse/_menu_items.html.erb +7 -0
- data/app/views/layouts/rails_pulse/_onboarding_banner.html.erb +8 -0
- data/app/views/layouts/rails_pulse/_setup_screen.html.erb +5 -5
- data/app/views/layouts/rails_pulse/application.html.erb +4 -4
- data/app/views/rails_pulse/components/_empty_state.html.erb +1 -1
- data/app/views/rails_pulse/components/_panel.html.erb +16 -6
- data/app/views/rails_pulse/components/_raw_data.html.erb +34 -0
- data/app/views/rails_pulse/dashboard/_chart_panel.html.erb +6 -8
- data/app/views/rails_pulse/dashboard/_deployments_panel.html.erb +64 -0
- data/app/views/rails_pulse/dashboard/_health_badge.html.erb +21 -10
- data/app/views/rails_pulse/dashboard/_storage_panel.html.erb +67 -0
- data/app/views/rails_pulse/dashboard/index.html.erb +22 -1
- data/app/views/rails_pulse/deployments/_status_badge.html.erb +5 -0
- data/app/views/rails_pulse/deployments/_table.html.erb +29 -0
- data/app/views/rails_pulse/deployments/index.html.erb +15 -0
- data/app/views/rails_pulse/deployments/show.html.erb +62 -0
- data/app/views/rails_pulse/exception_occurrences/show.html.erb +45 -0
- data/app/views/rails_pulse/exceptions/_backtrace.html.erb +33 -0
- data/app/views/rails_pulse/exceptions/_metric_cards.html.erb +6 -0
- data/app/views/rails_pulse/exceptions/_occurrences_table.html.erb +23 -0
- data/app/views/rails_pulse/exceptions/_status_badge.html.erb +6 -0
- data/app/views/rails_pulse/exceptions/_table.html.erb +33 -0
- data/app/views/rails_pulse/exceptions/index.html.erb +66 -0
- data/app/views/rails_pulse/exceptions/show.html.erb +52 -0
- data/app/views/rails_pulse/queries/_raw_data.html.erb +1 -23
- data/app/views/rails_pulse/requests/_table.html.erb +1 -1
- data/app/views/rails_pulse/routes/_requests_table.html.erb +5 -0
- data/app/views/rails_pulse/routes/_table.html.erb +22 -8
- data/app/views/rails_pulse/routes/index.html.erb +5 -1
- data/app/views/rails_pulse/routes/show.html.erb +5 -0
- data/app/views/rails_pulse/storage/show.html.erb +123 -0
- data/config/routes.rb +13 -4
- data/db/rails_pulse_migrate/20260506000001_create_rails_pulse_exceptions.rb +50 -0
- data/db/rails_pulse_migrate/20260507000001_add_actual_query_to_operations.rb +10 -5
- data/db/rails_pulse_migrate/20260610000001_add_controller_action_to_rails_pulse_routes.rb +8 -0
- data/db/rails_pulse_migrate/20260610000002_change_rails_pulse_routes_to_multi_verb_model.rb +185 -0
- data/db/rails_pulse_migrate/20260610000003_add_null_action_unique_index_to_routes.rb +32 -0
- data/db/rails_pulse_migrate/20260823000001_add_location_to_exception_groups.rb +12 -0
- data/db/rails_pulse_schema.rb +54 -6
- data/lib/generators/rails_pulse/base_methods.rb +2 -0
- data/lib/generators/rails_pulse/install_generator.rb +5 -0
- data/lib/generators/rails_pulse/templates/db/rails_pulse_schema.rb +54 -6
- data/lib/generators/rails_pulse/templates/rails_pulse.rb +152 -16
- data/lib/generators/rails_pulse/upgrade_generator.rb +166 -38
- data/lib/rails_pulse/cleanup_service.rb +110 -15
- data/lib/rails_pulse/configuration.rb +125 -6
- data/lib/rails_pulse/engine.rb +32 -17
- data/lib/rails_pulse/installers/config_updater.rb +202 -0
- data/lib/rails_pulse/job_run_collector.rb +43 -16
- data/lib/rails_pulse/middleware/asset_server.rb +11 -0
- data/lib/rails_pulse/middleware/request_collector.rb +9 -4
- data/lib/rails_pulse/packaged_assets.rb +147 -0
- data/lib/rails_pulse/route_indexes.rb +66 -0
- data/lib/rails_pulse/subscribers/exception_subscriber.rb +36 -0
- data/lib/rails_pulse/tracker.rb +12 -9
- data/lib/rails_pulse/version.rb +1 -1
- data/lib/rails_pulse.rb +1 -0
- data/lib/rails_pulse_server.ru +16 -47
- data/lib/tasks/rails_pulse.rake +83 -4
- data/lib/tasks/rails_pulse_assets.rake +15 -0
- data/lib/tasks/rails_pulse_tasks.rake +29 -0
- data/public/rails-pulse-assets/rails-pulse-icons.js +6 -2
- data/public/rails-pulse-assets/rails-pulse.css +1 -1
- data/public/rails-pulse-assets/rails-pulse.js +18 -117
- metadata +78 -37
- data/Rakefile +0 -554
- data/app/javascript/rails_pulse/controllers/form_controller.js +0 -39
- data/app/javascript/rails_pulse/controllers/period_selector_controller.js +0 -19
- data/app/javascript/rails_pulse/controllers/timezone_controller.js +0 -89
- data/app/javascript/rails_pulse/theme.js +0 -416
- data/config/brakeman.ignore +0 -190
- data/config/brakeman.yml +0 -68
- data/config/initializers/rails_pulse.rb +0 -287
- data/public/rails-pulse-assets/csp-test.js +0 -120
- data/public/rails-pulse-assets/rails-pulse-icons.js.map +0 -13
- data/public/rails-pulse-assets/rails-pulse.css.map +0 -1
- data/public/rails-pulse-assets/rails-pulse.js.map +0 -7
- data/vendor/assets/javascripts/rails-pulse-icons.js +0 -93
- data/vendor/assets/javascripts/rails-pulse-icons.js.map +0 -13
- data/vendor/assets/javascripts/rails-pulse.js +0 -157
- data/vendor/assets/javascripts/rails-pulse.js.map +0 -7
- data/vendor/assets/stylesheets/rails-pulse.css +0 -1
- 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
|
-
|
|
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
|
|
@@ -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
|
|
@@ -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?
|
|
@@ -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
|
-
#
|
|
29
|
-
#
|
|
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
|
-
|
|
32
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2
|
-
|
|
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
|
|
402
|
-
const endValue = dataZoom.endValue
|
|
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
|
-
|
|
414
|
-
|
|
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
|
|
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
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|