rails_pulse 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +638 -0
- data/Rakefile +207 -0
- data/app/assets/images/rails_pulse/dashboard.png +0 -0
- data/app/assets/images/rails_pulse/menu.svg +1 -0
- data/app/assets/images/rails_pulse/rails-pulse-logo.png +0 -0
- data/app/assets/images/rails_pulse/request.png +0 -0
- data/app/assets/images/rails_pulse/routes.png +0 -0
- data/app/assets/stylesheets/rails_pulse/application.css +102 -0
- data/app/assets/stylesheets/rails_pulse/components/alert.css +24 -0
- data/app/assets/stylesheets/rails_pulse/components/badge.css +58 -0
- data/app/assets/stylesheets/rails_pulse/components/base.css +79 -0
- data/app/assets/stylesheets/rails_pulse/components/breadcrumb.css +31 -0
- data/app/assets/stylesheets/rails_pulse/components/button.css +99 -0
- data/app/assets/stylesheets/rails_pulse/components/card.css +19 -0
- data/app/assets/stylesheets/rails_pulse/components/chart.css +18 -0
- data/app/assets/stylesheets/rails_pulse/components/csp_safe_positioning.css +86 -0
- data/app/assets/stylesheets/rails_pulse/components/descriptive_list.css +9 -0
- data/app/assets/stylesheets/rails_pulse/components/dialog.css +56 -0
- data/app/assets/stylesheets/rails_pulse/components/flash.css +47 -0
- data/app/assets/stylesheets/rails_pulse/components/input.css +80 -0
- data/app/assets/stylesheets/rails_pulse/components/layouts.css +63 -0
- data/app/assets/stylesheets/rails_pulse/components/menu.css +43 -0
- data/app/assets/stylesheets/rails_pulse/components/popover.css +36 -0
- data/app/assets/stylesheets/rails_pulse/components/prose.css +144 -0
- data/app/assets/stylesheets/rails_pulse/components/row.css +24 -0
- data/app/assets/stylesheets/rails_pulse/components/sidebar_menu.css +79 -0
- data/app/assets/stylesheets/rails_pulse/components/skeleton.css +5 -0
- data/app/assets/stylesheets/rails_pulse/components/table.css +37 -0
- data/app/assets/stylesheets/rails_pulse/components/utilities.css +36 -0
- data/app/controllers/concerns/chart_table_concern.rb +82 -0
- data/app/controllers/concerns/response_range_concern.rb +24 -0
- data/app/controllers/concerns/time_range_concern.rb +67 -0
- data/app/controllers/concerns/zoom_range_concern.rb +40 -0
- data/app/controllers/rails_pulse/application_controller.rb +67 -0
- data/app/controllers/rails_pulse/assets_controller.rb +33 -0
- data/app/controllers/rails_pulse/caches_controller.rb +115 -0
- data/app/controllers/rails_pulse/csp_test_controller.rb +57 -0
- data/app/controllers/rails_pulse/dashboard_controller.rb +6 -0
- data/app/controllers/rails_pulse/operations_controller.rb +219 -0
- data/app/controllers/rails_pulse/queries_controller.rb +121 -0
- data/app/controllers/rails_pulse/requests_controller.rb +69 -0
- data/app/controllers/rails_pulse/routes_controller.rb +99 -0
- data/app/helpers/rails_pulse/application_helper.rb +111 -0
- data/app/helpers/rails_pulse/breadcrumbs_helper.rb +62 -0
- data/app/helpers/rails_pulse/cached_component_helper.rb +73 -0
- data/app/helpers/rails_pulse/chart_formatters.rb +43 -0
- data/app/helpers/rails_pulse/chart_helper.rb +140 -0
- data/app/helpers/rails_pulse/formatting_helper.rb +29 -0
- data/app/helpers/rails_pulse/status_helper.rb +279 -0
- data/app/helpers/rails_pulse/table_helper.rb +54 -0
- data/app/javascript/rails_pulse/application.js +119 -0
- data/app/javascript/rails_pulse/controllers/color_scheme_controller.js +20 -0
- data/app/javascript/rails_pulse/controllers/context_menu_controller.js +16 -0
- data/app/javascript/rails_pulse/controllers/dialog_controller.js +21 -0
- data/app/javascript/rails_pulse/controllers/expandable_row_controller.js +67 -0
- data/app/javascript/rails_pulse/controllers/form_controller.js +39 -0
- data/app/javascript/rails_pulse/controllers/icon_controller.js +170 -0
- data/app/javascript/rails_pulse/controllers/index_controller.js +230 -0
- data/app/javascript/rails_pulse/controllers/menu_controller.js +60 -0
- data/app/javascript/rails_pulse/controllers/pagination_controller.js +69 -0
- data/app/javascript/rails_pulse/controllers/popover_controller.js +91 -0
- data/app/javascript/rails_pulse/controllers/timezone_controller.js +106 -0
- data/app/javascript/rails_pulse/theme.js +416 -0
- data/app/jobs/rails_pulse/application_job.rb +4 -0
- data/app/jobs/rails_pulse/cleanup_job.rb +21 -0
- data/app/mailers/rails_pulse/application_mailer.rb +6 -0
- data/app/models/rails_pulse/application_record.rb +7 -0
- data/app/models/rails_pulse/component_cache_key.rb +33 -0
- data/app/models/rails_pulse/dashboard/charts/average_response_time.rb +27 -0
- data/app/models/rails_pulse/dashboard/charts/p95_response_time.rb +37 -0
- data/app/models/rails_pulse/dashboard/tables/slow_queries.rb +59 -0
- data/app/models/rails_pulse/dashboard/tables/slow_routes.rb +45 -0
- data/app/models/rails_pulse/operation.rb +87 -0
- data/app/models/rails_pulse/queries/cards/average_query_times.rb +52 -0
- data/app/models/rails_pulse/queries/cards/execution_rate.rb +57 -0
- data/app/models/rails_pulse/queries/cards/percentile_query_times.rb +71 -0
- data/app/models/rails_pulse/queries/charts/average_query_times.rb +112 -0
- data/app/models/rails_pulse/query.rb +58 -0
- data/app/models/rails_pulse/request.rb +64 -0
- data/app/models/rails_pulse/requests/charts/average_response_times.rb +99 -0
- data/app/models/rails_pulse/requests/charts/operations_chart.rb +35 -0
- data/app/models/rails_pulse/route.rb +77 -0
- data/app/models/rails_pulse/routes/cards/average_response_times.rb +54 -0
- data/app/models/rails_pulse/routes/cards/error_rate_per_route.rb +73 -0
- data/app/models/rails_pulse/routes/cards/percentile_response_times.rb +73 -0
- data/app/models/rails_pulse/routes/cards/request_count_totals.rb +59 -0
- data/app/models/rails_pulse/routes/charts/average_response_times.rb +115 -0
- data/app/models/rails_pulse/routes/tables/index.rb +63 -0
- data/app/services/rails_pulse/sql_query_normalizer.rb +124 -0
- data/app/views/layouts/rails_pulse/_menu_items.html.erb +19 -0
- data/app/views/layouts/rails_pulse/_sidebar_menu.html.erb +44 -0
- data/app/views/layouts/rails_pulse/application.html.erb +72 -0
- data/app/views/rails_pulse/caches/show.html.erb +9 -0
- data/app/views/rails_pulse/components/_breadcrumbs.html.erb +12 -0
- data/app/views/rails_pulse/components/_code_panel.html.erb +12 -0
- data/app/views/rails_pulse/components/_metric_card.html.erb +55 -0
- data/app/views/rails_pulse/components/_metric_row.html.erb +9 -0
- data/app/views/rails_pulse/components/_operation_details_popover.html.erb +241 -0
- data/app/views/rails_pulse/components/_panel.html.erb +56 -0
- data/app/views/rails_pulse/components/_sparkline_stats.html.erb +15 -0
- data/app/views/rails_pulse/components/_table.html.erb +50 -0
- data/app/views/rails_pulse/components/_table_head.html.erb +20 -0
- data/app/views/rails_pulse/components/_table_pagination.html.erb +45 -0
- data/app/views/rails_pulse/components/_time_period.html.erb +16 -0
- data/app/views/rails_pulse/csp_test/show.html.erb +207 -0
- data/app/views/rails_pulse/dashboard/charts/_bar_chart.html.erb +1 -0
- data/app/views/rails_pulse/dashboard/index.html.erb +64 -0
- data/app/views/rails_pulse/dashboard/tables/_routes_table.html.erb +32 -0
- data/app/views/rails_pulse/dashboard/tables/_standard_table.html.erb +1 -0
- data/app/views/rails_pulse/operations/_operation_analysis_application.html.erb +43 -0
- data/app/views/rails_pulse/operations/_operation_analysis_database.html.erb +12 -0
- data/app/views/rails_pulse/operations/_operation_analysis_generic.html.erb +15 -0
- data/app/views/rails_pulse/operations/_operation_analysis_other.html.erb +69 -0
- data/app/views/rails_pulse/operations/_operation_analysis_view.html.erb +39 -0
- data/app/views/rails_pulse/operations/show.html.erb +79 -0
- data/app/views/rails_pulse/queries/_show_table.html.erb +19 -0
- data/app/views/rails_pulse/queries/_table.html.erb +31 -0
- data/app/views/rails_pulse/queries/index.html.erb +64 -0
- data/app/views/rails_pulse/queries/show.html.erb +86 -0
- data/app/views/rails_pulse/requests/_operations.html.erb +85 -0
- data/app/views/rails_pulse/requests/_table.html.erb +31 -0
- data/app/views/rails_pulse/requests/index.html.erb +64 -0
- data/app/views/rails_pulse/requests/show.html.erb +44 -0
- data/app/views/rails_pulse/routes/_table.html.erb +29 -0
- data/app/views/rails_pulse/routes/index.html.erb +65 -0
- data/app/views/rails_pulse/routes/show.html.erb +67 -0
- data/app/views/rails_pulse/skeletons/_chart.html.erb +3 -0
- data/app/views/rails_pulse/skeletons/_metric_card.html.erb +20 -0
- data/app/views/rails_pulse/skeletons/_panel.html.erb +19 -0
- data/app/views/rails_pulse/skeletons/_table.html.erb +8 -0
- data/config/importmap.rb +12 -0
- data/config/initializers/rails_charts_csp_patch.rb +83 -0
- data/config/initializers/rails_pulse.rb +198 -0
- data/config/routes.rb +16 -0
- data/db/migrate/20250227235904_create_routes.rb +12 -0
- data/db/migrate/20250227235915_create_requests.rb +19 -0
- data/db/migrate/20250228000000_create_queries.rb +14 -0
- data/db/migrate/20250228000056_create_operations.rb +24 -0
- data/lib/generators/rails_pulse/install_generator.rb +17 -0
- data/lib/generators/rails_pulse/templates/rails_pulse.rb +198 -0
- data/lib/rails_pulse/cleanup_service.rb +212 -0
- data/lib/rails_pulse/configuration.rb +176 -0
- data/lib/rails_pulse/engine.rb +88 -0
- data/lib/rails_pulse/middleware/asset_server.rb +84 -0
- data/lib/rails_pulse/middleware/request_collector.rb +120 -0
- data/lib/rails_pulse/migration.rb +29 -0
- data/lib/rails_pulse/subscribers/operation_subscriber.rb +280 -0
- data/lib/rails_pulse/version.rb +3 -0
- data/lib/rails_pulse.rb +38 -0
- data/lib/tasks/rails_pulse_tasks.rake +138 -0
- data/public/rails-pulse-assets/csp-test.js +110 -0
- data/public/rails-pulse-assets/rails-pulse-icons.js +89 -0
- data/public/rails-pulse-assets/rails-pulse-icons.js.map +13 -0
- data/public/rails-pulse-assets/rails-pulse.css +1 -0
- data/public/rails-pulse-assets/rails-pulse.css.map +1 -0
- data/public/rails-pulse-assets/rails-pulse.js +183 -0
- data/public/rails-pulse-assets/rails-pulse.js.map +7 -0
- metadata +339 -0
@@ -0,0 +1,77 @@
|
|
1
|
+
module RailsPulse
|
2
|
+
class Route < RailsPulse::ApplicationRecord
|
3
|
+
self.table_name = "rails_pulse_routes"
|
4
|
+
|
5
|
+
# Associations
|
6
|
+
has_many :requests, class_name: "RailsPulse::Request", foreign_key: "route_id", dependent: :restrict_with_exception
|
7
|
+
|
8
|
+
# Validations
|
9
|
+
validates :method, presence: true
|
10
|
+
validates :path, presence: true, uniqueness: { scope: :method, message: "and method combination must be unique" }
|
11
|
+
|
12
|
+
# Scopes (optional, for convenience)
|
13
|
+
scope :by_method_and_path, ->(method, path) { where(method: method, path: path).first_or_create }
|
14
|
+
|
15
|
+
def self.ransackable_attributes(auth_object = nil)
|
16
|
+
%w[path average_response_time_ms max_response_time_ms request_count requests_per_minute occurred_at requests_occurred_at error_count error_rate_percentage status_indicator]
|
17
|
+
end
|
18
|
+
|
19
|
+
def self.ransackable_associations(auth_object = nil)
|
20
|
+
%w[requests]
|
21
|
+
end
|
22
|
+
|
23
|
+
ransacker :average_response_time_ms do
|
24
|
+
Arel.sql("COALESCE(AVG(rails_pulse_requests.duration), 0)")
|
25
|
+
end
|
26
|
+
|
27
|
+
ransacker :request_count do
|
28
|
+
Arel.sql("COUNT(rails_pulse_requests.id)")
|
29
|
+
end
|
30
|
+
|
31
|
+
ransacker :occurred_at do |parent|
|
32
|
+
parent.table[:occurred_at]
|
33
|
+
end
|
34
|
+
|
35
|
+
ransacker :requests_occurred_at do |_parent|
|
36
|
+
Arel.sql("rails_pulse_requests.occurred_at")
|
37
|
+
end
|
38
|
+
|
39
|
+
ransacker :error_count do
|
40
|
+
Arel.sql(
|
41
|
+
"COALESCE(SUM(CASE WHEN rails_pulse_requests.is_error = true THEN 1 ELSE 0 END), 0)"
|
42
|
+
)
|
43
|
+
end
|
44
|
+
|
45
|
+
ransacker :max_response_time_ms do
|
46
|
+
Arel.sql("COALESCE(MAX(rails_pulse_requests.duration), 0)")
|
47
|
+
end
|
48
|
+
|
49
|
+
ransacker :error_rate_percentage do
|
50
|
+
Arel.sql("CASE WHEN COUNT(rails_pulse_requests.id) > 0 THEN ROUND((COALESCE(SUM(CASE WHEN rails_pulse_requests.is_error = true THEN 1 ELSE 0 END), 0) * 100.0) / COUNT(rails_pulse_requests.id), 2) ELSE 0 END")
|
51
|
+
end
|
52
|
+
|
53
|
+
ransacker :requests_per_minute do
|
54
|
+
# Use a simpler database-agnostic approach - this is mainly used for sorting/filtering
|
55
|
+
# so exact precision isn't as critical as avoiding database-specific functions
|
56
|
+
Arel.sql("COUNT(rails_pulse_requests.id)")
|
57
|
+
end
|
58
|
+
|
59
|
+
# Remove the problematic ransacker that causes SQL syntax errors
|
60
|
+
# The status_indicator will be handled differently or removed from filtering
|
61
|
+
# ransacker :status_indicator do
|
62
|
+
# # Removed to fix SQL generation issues in tests
|
63
|
+
# end
|
64
|
+
|
65
|
+
def to_breadcrumb
|
66
|
+
path
|
67
|
+
end
|
68
|
+
|
69
|
+
def self.average_response_time
|
70
|
+
joins(:requests).average("rails_pulse_requests.duration") || 0
|
71
|
+
end
|
72
|
+
|
73
|
+
def path_and_method
|
74
|
+
"#{path} #{method}"
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|
@@ -0,0 +1,54 @@
|
|
1
|
+
module RailsPulse
|
2
|
+
module Routes
|
3
|
+
module Cards
|
4
|
+
class AverageResponseTimes
|
5
|
+
def initialize(route:)
|
6
|
+
@route = route
|
7
|
+
end
|
8
|
+
|
9
|
+
def to_metric_card
|
10
|
+
requests = if @route
|
11
|
+
RailsPulse::Request.where(route: @route)
|
12
|
+
else
|
13
|
+
RailsPulse::Request.all
|
14
|
+
end
|
15
|
+
|
16
|
+
requests = requests.where("occurred_at >= ?", 2.weeks.ago.beginning_of_day)
|
17
|
+
|
18
|
+
# Calculate overall average response time
|
19
|
+
average_response_time = requests.average(:duration)&.round(0) || 0
|
20
|
+
|
21
|
+
# Calculate trend by comparing last 7 days vs previous 7 days
|
22
|
+
last_7_days = 7.days.ago.beginning_of_day
|
23
|
+
previous_7_days = 14.days.ago.beginning_of_day
|
24
|
+
current_period_avg = requests.where("occurred_at >= ?", last_7_days).average(:duration) || 0
|
25
|
+
previous_period_avg = requests.where("occurred_at >= ? AND occurred_at < ?", previous_7_days, last_7_days).average(:duration) || 0
|
26
|
+
|
27
|
+
percentage = previous_period_avg.zero? ? 0 : ((previous_period_avg - current_period_avg) / previous_period_avg * 100).abs.round(1)
|
28
|
+
trend_icon = percentage < 0.1 ? "move-right" : current_period_avg < previous_period_avg ? "trending-down" : "trending-up"
|
29
|
+
trend_amount = previous_period_avg.zero? ? "0%" : "#{percentage}%"
|
30
|
+
|
31
|
+
sparkline_data = requests
|
32
|
+
.group_by_week(:occurred_at, time_zone: "UTC")
|
33
|
+
.average(:duration)
|
34
|
+
.each_with_object({}) do |(date, avg), hash|
|
35
|
+
formatted_date = date.strftime("%b %-d")
|
36
|
+
value = avg&.round(0) || 0
|
37
|
+
hash[formatted_date] = {
|
38
|
+
value: value
|
39
|
+
}
|
40
|
+
end
|
41
|
+
|
42
|
+
{
|
43
|
+
title: "Average Response Time",
|
44
|
+
summary: "#{average_response_time} ms",
|
45
|
+
line_chart_data: sparkline_data,
|
46
|
+
trend_icon: trend_icon,
|
47
|
+
trend_amount: trend_amount,
|
48
|
+
trend_text: "Compared to last week"
|
49
|
+
}
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|
@@ -0,0 +1,73 @@
|
|
1
|
+
module RailsPulse
|
2
|
+
module Routes
|
3
|
+
module Cards
|
4
|
+
class ErrorRatePerRoute
|
5
|
+
def initialize(route: nil)
|
6
|
+
@route = route
|
7
|
+
end
|
8
|
+
|
9
|
+
def to_metric_card
|
10
|
+
# Calculate error rate for each route or a specific route
|
11
|
+
routes = if @route
|
12
|
+
RailsPulse::Route.where(id: @route)
|
13
|
+
else
|
14
|
+
RailsPulse::Route.all
|
15
|
+
end
|
16
|
+
|
17
|
+
routes = routes.where("occurred_at >= ?", 2.weeks.ago.beginning_of_day)
|
18
|
+
|
19
|
+
error_rates = routes.joins(:requests)
|
20
|
+
.select("rails_pulse_routes.id, rails_pulse_routes.path, COUNT(rails_pulse_requests.id) as total_requests, SUM(CASE WHEN rails_pulse_requests.is_error = true THEN 1 ELSE 0 END) as error_count")
|
21
|
+
.group("rails_pulse_routes.id, rails_pulse_routes.path")
|
22
|
+
.map do |route|
|
23
|
+
error_rate = route.error_count.to_f / route.total_requests * 100
|
24
|
+
{
|
25
|
+
path: route.path,
|
26
|
+
error_rate: error_rate.round(2)
|
27
|
+
}
|
28
|
+
end
|
29
|
+
|
30
|
+
# Calculate overall error rate summary as errors per day
|
31
|
+
requests = @route ? RailsPulse::Request.where(route: @route) : RailsPulse::Request.all
|
32
|
+
total_errors = requests.where(is_error: true).count
|
33
|
+
min_time = requests.minimum(:occurred_at)
|
34
|
+
max_time = requests.maximum(:occurred_at)
|
35
|
+
total_days = min_time && max_time && min_time != max_time ? (max_time - min_time) / 1.day : 1
|
36
|
+
errors_per_day = total_errors / total_days
|
37
|
+
error_rate_summary = "#{errors_per_day.round(2)} / day"
|
38
|
+
|
39
|
+
# Generate sparkline data
|
40
|
+
sparkline_data = requests
|
41
|
+
.where(is_error: true)
|
42
|
+
.group_by_week(:occurred_at, time_zone: "UTC")
|
43
|
+
.count
|
44
|
+
.each_with_object({}) do |(date, count), hash|
|
45
|
+
formatted_date = date.strftime("%b %-d")
|
46
|
+
hash[formatted_date] = {
|
47
|
+
value: count
|
48
|
+
}
|
49
|
+
end
|
50
|
+
|
51
|
+
# Determine trend direction and amount
|
52
|
+
last_7_days = 7.days.ago.beginning_of_day
|
53
|
+
previous_7_days = 14.days.ago.beginning_of_day
|
54
|
+
current_period_errors = requests.where("occurred_at >= ? AND is_error = ?", last_7_days, true).count
|
55
|
+
previous_period_errors = requests.where("occurred_at >= ? AND occurred_at < ? AND is_error = ?", previous_7_days, last_7_days, true).count
|
56
|
+
|
57
|
+
trend_amount = previous_period_errors.zero? ? "0%" : "#{((current_period_errors - previous_period_errors) / previous_period_errors.to_f * 100).round(1)}%"
|
58
|
+
trend_icon = trend_amount.to_f < 0.1 ? "move-right" : current_period_errors < previous_period_errors ? "trending-down" : "trending-up"
|
59
|
+
|
60
|
+
{
|
61
|
+
title: "Error Rate Per Route",
|
62
|
+
data: error_rates,
|
63
|
+
summary: error_rate_summary,
|
64
|
+
line_chart_data: sparkline_data,
|
65
|
+
trend_icon: trend_icon,
|
66
|
+
trend_amount: trend_amount,
|
67
|
+
trend_text: "Compared to last week"
|
68
|
+
}
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|
73
|
+
end
|
@@ -0,0 +1,73 @@
|
|
1
|
+
module RailsPulse
|
2
|
+
module Routes
|
3
|
+
module Cards
|
4
|
+
class PercentileResponseTimes
|
5
|
+
def initialize(route: nil)
|
6
|
+
@route = route
|
7
|
+
end
|
8
|
+
|
9
|
+
def to_metric_card
|
10
|
+
requests = if @route
|
11
|
+
RailsPulse::Request.where(route: @route)
|
12
|
+
else
|
13
|
+
RailsPulse::Request.all
|
14
|
+
end
|
15
|
+
|
16
|
+
requests = requests.where("occurred_at >= ?", 2.weeks.ago.beginning_of_day)
|
17
|
+
|
18
|
+
# Calculate overall 95th percentile response time
|
19
|
+
count = requests.count
|
20
|
+
percentile_95th = if count > 0
|
21
|
+
requests.select("duration").order("duration").limit(1).offset((count * 0.95).floor).pluck(:duration).first.round(0) || 0
|
22
|
+
else
|
23
|
+
0
|
24
|
+
end
|
25
|
+
|
26
|
+
# Calculate trend by comparing last 7 days vs previous 7 days for 95th percentile
|
27
|
+
last_7_days = 7.days.ago.beginning_of_day
|
28
|
+
previous_7_days = 14.days.ago.beginning_of_day
|
29
|
+
|
30
|
+
current_period = requests.where("occurred_at >= ?", last_7_days)
|
31
|
+
current_count = current_period.count
|
32
|
+
current_period_95th = if current_count > 0
|
33
|
+
current_period.select("duration").order("duration").limit(1).offset((current_count * 0.95).floor).pluck(:duration).first || 0
|
34
|
+
else
|
35
|
+
0
|
36
|
+
end
|
37
|
+
|
38
|
+
previous_period = requests.where("occurred_at >= ? AND occurred_at < ?", previous_7_days, last_7_days)
|
39
|
+
previous_count = previous_period.count
|
40
|
+
previous_period_95th = if previous_count > 0
|
41
|
+
previous_period.select("duration").order("duration").limit(1).offset((previous_count * 0.95).floor).pluck(:duration).first || 0
|
42
|
+
else
|
43
|
+
0
|
44
|
+
end
|
45
|
+
|
46
|
+
percentage = previous_period_95th.zero? ? 0 : ((previous_period_95th - current_period_95th) / previous_period_95th * 100).abs.round(1)
|
47
|
+
trend_icon = percentage < 0.1 ? "move-right" : current_period_95th < previous_period_95th ? "trending-down" : "trending-up"
|
48
|
+
trend_amount = previous_period_95th.zero? ? "0%" : "#{percentage}%"
|
49
|
+
|
50
|
+
sparkline_data = requests
|
51
|
+
.group_by_week(:occurred_at, time_zone: "UTC")
|
52
|
+
.average(:duration)
|
53
|
+
.each_with_object({}) do |(date, avg), hash|
|
54
|
+
formatted_date = date.strftime("%b %-d")
|
55
|
+
value = avg&.round(0) || 0
|
56
|
+
hash[formatted_date] = {
|
57
|
+
value: value
|
58
|
+
}
|
59
|
+
end
|
60
|
+
|
61
|
+
{
|
62
|
+
title: "95th Percentile Response Time",
|
63
|
+
summary: "#{percentile_95th} ms",
|
64
|
+
line_chart_data: sparkline_data,
|
65
|
+
trend_icon: trend_icon,
|
66
|
+
trend_amount: trend_amount,
|
67
|
+
trend_text: "Compared to last week"
|
68
|
+
}
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|
73
|
+
end
|
@@ -0,0 +1,59 @@
|
|
1
|
+
module RailsPulse
|
2
|
+
module Routes
|
3
|
+
module Cards
|
4
|
+
class RequestCountTotals
|
5
|
+
def initialize(route: nil)
|
6
|
+
@route = route
|
7
|
+
end
|
8
|
+
|
9
|
+
def to_metric_card
|
10
|
+
requests = if @route
|
11
|
+
RailsPulse::Request.where(route: @route)
|
12
|
+
else
|
13
|
+
RailsPulse::Request.all
|
14
|
+
end
|
15
|
+
|
16
|
+
requests = requests.where("occurred_at >= ?", 2.weeks.ago.beginning_of_day)
|
17
|
+
|
18
|
+
# Calculate total request count
|
19
|
+
total_request_count = requests.count
|
20
|
+
|
21
|
+
# Calculate trend by comparing last 7 days vs previous 7 days
|
22
|
+
last_7_days = 7.days.ago.beginning_of_day
|
23
|
+
previous_7_days = 14.days.ago.beginning_of_day
|
24
|
+
current_period_count = requests.where("occurred_at >= ?", last_7_days).count
|
25
|
+
previous_period_count = requests.where("occurred_at >= ? AND occurred_at < ?", previous_7_days, last_7_days).count
|
26
|
+
|
27
|
+
percentage = previous_period_count.zero? ? 0 : ((previous_period_count - current_period_count) / previous_period_count.to_f * 100).abs.round(1)
|
28
|
+
trend_icon = percentage < 0.1 ? "move-right" : current_period_count < previous_period_count ? "trending-down" : "trending-up"
|
29
|
+
trend_amount = previous_period_count.zero? ? "0%" : "#{percentage}%"
|
30
|
+
|
31
|
+
sparkline_data = requests
|
32
|
+
.group_by_week(:occurred_at, time_zone: "UTC")
|
33
|
+
.count
|
34
|
+
.each_with_object({}) do |(date, count), hash|
|
35
|
+
formatted_date = date.strftime("%b %-d")
|
36
|
+
hash[formatted_date] = {
|
37
|
+
value: count
|
38
|
+
}
|
39
|
+
end
|
40
|
+
|
41
|
+
# Calculate average requests per minute
|
42
|
+
min_time = requests.minimum(:occurred_at)
|
43
|
+
max_time = requests.maximum(:occurred_at)
|
44
|
+
total_minutes = min_time && max_time && min_time != max_time ? (max_time - min_time) / 60.0 : 1
|
45
|
+
average_requests_per_minute = total_request_count / total_minutes
|
46
|
+
|
47
|
+
{
|
48
|
+
title: "Request Count Total",
|
49
|
+
summary: "#{average_requests_per_minute.round(2)} / min",
|
50
|
+
line_chart_data: sparkline_data,
|
51
|
+
trend_icon: trend_icon,
|
52
|
+
trend_amount: trend_amount,
|
53
|
+
trend_text: "Compared to last week"
|
54
|
+
}
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
@@ -0,0 +1,115 @@
|
|
1
|
+
module RailsPulse
|
2
|
+
module Routes
|
3
|
+
module Charts
|
4
|
+
class AverageResponseTimes
|
5
|
+
def initialize(ransack_query:, group_by: :group_by_day, route: nil)
|
6
|
+
@ransack_query = ransack_query
|
7
|
+
@group_by = group_by
|
8
|
+
@route = route
|
9
|
+
end
|
10
|
+
|
11
|
+
def to_rails_chart
|
12
|
+
# Get actual data using existing logic
|
13
|
+
actual_data = if @route
|
14
|
+
# These are the requests for the specific route so it will just be a collection of Requests that we can
|
15
|
+
# filter and sort using the attributes on each Request
|
16
|
+
@ransack_query.result(distinct: false)
|
17
|
+
.public_send(@group_by, "occurred_at", series: true, time_zone: "UTC")
|
18
|
+
.average(:duration)
|
19
|
+
else
|
20
|
+
# Use the existing query structure with left_joins from ransack
|
21
|
+
@ransack_query.result(distinct: false)
|
22
|
+
.left_joins(:requests)
|
23
|
+
.public_send(
|
24
|
+
@group_by,
|
25
|
+
"rails_pulse_requests.occurred_at",
|
26
|
+
series: true,
|
27
|
+
time_zone: "UTC"
|
28
|
+
)
|
29
|
+
.average("rails_pulse_requests.duration")
|
30
|
+
end
|
31
|
+
|
32
|
+
# Create full time range and fill in missing periods
|
33
|
+
fill_missing_periods(actual_data)
|
34
|
+
end
|
35
|
+
|
36
|
+
private
|
37
|
+
|
38
|
+
def fill_missing_periods(actual_data)
|
39
|
+
# Extract actual time range from ransack query conditions
|
40
|
+
start_time, end_time = extract_time_range_from_ransack
|
41
|
+
|
42
|
+
# Create time range based on grouping type
|
43
|
+
case @group_by
|
44
|
+
when :group_by_hour
|
45
|
+
time_range = generate_hour_range(start_time, end_time)
|
46
|
+
else # :group_by_day
|
47
|
+
time_range = generate_day_range(start_time, end_time)
|
48
|
+
end
|
49
|
+
|
50
|
+
# Fill in all periods with zero values for missing periods
|
51
|
+
time_range.each_with_object({}) do |period, result|
|
52
|
+
occurred_at = period.is_a?(String) ? Time.parse(period) : period
|
53
|
+
occurred_at = (occurred_at.is_a?(Time) || occurred_at.is_a?(Date)) ? occurred_at : Time.current
|
54
|
+
|
55
|
+
normalized_occurred_at =
|
56
|
+
case @group_by
|
57
|
+
when :group_by_hour
|
58
|
+
occurred_at&.beginning_of_hour || occurred_at
|
59
|
+
when :group_by_day
|
60
|
+
occurred_at&.beginning_of_day || occurred_at
|
61
|
+
else
|
62
|
+
occurred_at
|
63
|
+
end
|
64
|
+
|
65
|
+
# Use actual data if available, otherwise default to 0
|
66
|
+
average_duration = actual_data[period] || 0
|
67
|
+
result[normalized_occurred_at.to_i] = {
|
68
|
+
value: average_duration.to_f
|
69
|
+
}
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
def generate_day_range(start_time, end_time)
|
74
|
+
(start_time.to_date..end_time.to_date).map(&:beginning_of_day)
|
75
|
+
end
|
76
|
+
|
77
|
+
def generate_hour_range(start_time, end_time)
|
78
|
+
current = start_time
|
79
|
+
hours = []
|
80
|
+
while current <= end_time
|
81
|
+
hours << current
|
82
|
+
current += 1.hour
|
83
|
+
end
|
84
|
+
hours
|
85
|
+
end
|
86
|
+
|
87
|
+
def extract_time_range_from_ransack
|
88
|
+
# Extract time range from ransack conditions
|
89
|
+
conditions = @ransack_query.conditions
|
90
|
+
|
91
|
+
if @route
|
92
|
+
# For specific route queries, look for occurred_at conditions
|
93
|
+
start_condition = conditions.find { |c| c.a.first == "occurred_at" && c.p == "gteq" }
|
94
|
+
end_condition = conditions.find { |c| c.a.first == "occurred_at" && c.p == "lt" }
|
95
|
+
else
|
96
|
+
# For general route queries, look for requests_occurred_at conditions
|
97
|
+
start_condition = conditions.find { |c| c.a.first == "requests_occurred_at" && c.p == "gteq" }
|
98
|
+
end_condition = conditions.find { |c| c.a.first == "requests_occurred_at" && c.p == "lt" }
|
99
|
+
end
|
100
|
+
|
101
|
+
start_time = start_condition&.v || 2.weeks.ago
|
102
|
+
end_time = end_condition&.v || Time.current
|
103
|
+
|
104
|
+
# Normalize time boundaries based on grouping
|
105
|
+
case @group_by
|
106
|
+
when :group_by_hour
|
107
|
+
[ start_time.beginning_of_hour, end_time.beginning_of_hour ]
|
108
|
+
else
|
109
|
+
[ start_time.beginning_of_day, end_time.beginning_of_day ]
|
110
|
+
end
|
111
|
+
end
|
112
|
+
end
|
113
|
+
end
|
114
|
+
end
|
115
|
+
end
|
@@ -0,0 +1,63 @@
|
|
1
|
+
module RailsPulse
|
2
|
+
module Routes
|
3
|
+
module Tables
|
4
|
+
class Index
|
5
|
+
def initialize(ransack_query:, start_time:, params:)
|
6
|
+
@ransack_query = ransack_query
|
7
|
+
@start_time = start_time
|
8
|
+
@params = params
|
9
|
+
end
|
10
|
+
|
11
|
+
def to_table
|
12
|
+
# Pre-calculate values to avoid SQL injection and improve readability
|
13
|
+
minutes_elapsed = calculate_minutes_elapsed
|
14
|
+
|
15
|
+
# Get thresholds with safe defaults to avoid nil access errors
|
16
|
+
config = RailsPulse.configuration rescue nil
|
17
|
+
thresholds = config&.route_thresholds || { slow: 500, very_slow: 1500, critical: 3000 }
|
18
|
+
|
19
|
+
requests_per_minute_divisor = minutes_elapsed > 0 ? minutes_elapsed : 1
|
20
|
+
|
21
|
+
status_sql = build_status_sql(thresholds)
|
22
|
+
|
23
|
+
@ransack_query.result(distinct: false)
|
24
|
+
.left_joins(:requests)
|
25
|
+
.group("rails_pulse_routes.id")
|
26
|
+
.select(
|
27
|
+
"rails_pulse_routes.*",
|
28
|
+
"COALESCE(AVG(rails_pulse_requests.duration), 0) AS average_response_time_ms",
|
29
|
+
"COUNT(rails_pulse_requests.id) AS request_count",
|
30
|
+
"COALESCE(COUNT(rails_pulse_requests.id) / #{requests_per_minute_divisor}, 0) AS requests_per_minute",
|
31
|
+
"COALESCE(SUM(CASE WHEN rails_pulse_requests.is_error = true THEN 1 ELSE 0 END), 0) AS error_count",
|
32
|
+
"CASE WHEN COUNT(rails_pulse_requests.id) > 0 THEN ROUND((COALESCE(SUM(CASE WHEN rails_pulse_requests.is_error = true THEN 1 ELSE 0 END), 0) * 100.0) / COUNT(rails_pulse_requests.id), 2) ELSE 0 END AS error_rate_percentage",
|
33
|
+
"COALESCE(MAX(rails_pulse_requests.duration), 0) AS max_response_time_ms",
|
34
|
+
"#{status_sql} AS status_indicator"
|
35
|
+
)
|
36
|
+
end
|
37
|
+
|
38
|
+
private
|
39
|
+
|
40
|
+
def calculate_minutes_elapsed
|
41
|
+
start_timestamp = Time.at(@start_time.to_i).utc
|
42
|
+
((Time.current.utc - start_timestamp) / 60.0).round(2)
|
43
|
+
end
|
44
|
+
|
45
|
+
def build_status_sql(thresholds)
|
46
|
+
# Ensure all thresholds have default values
|
47
|
+
slow = thresholds[:slow] || 500
|
48
|
+
very_slow = thresholds[:very_slow] || 1500
|
49
|
+
critical = thresholds[:critical] || 3000
|
50
|
+
|
51
|
+
<<-SQL.squish
|
52
|
+
CASE
|
53
|
+
WHEN COALESCE(AVG(rails_pulse_requests.duration), 0) >= #{critical} THEN 3
|
54
|
+
WHEN COALESCE(AVG(rails_pulse_requests.duration), 0) >= #{very_slow} THEN 2
|
55
|
+
WHEN COALESCE(AVG(rails_pulse_requests.duration), 0) >= #{slow} THEN 1
|
56
|
+
ELSE 0
|
57
|
+
END
|
58
|
+
SQL
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
@@ -0,0 +1,124 @@
|
|
1
|
+
module RailsPulse
|
2
|
+
class SqlQueryNormalizer
|
3
|
+
# Smart normalization: preserve table/column names, replace only literal values
|
4
|
+
def self.normalize(query_string)
|
5
|
+
new(query_string).normalize
|
6
|
+
end
|
7
|
+
|
8
|
+
def initialize(query_string)
|
9
|
+
@query_string = query_string
|
10
|
+
end
|
11
|
+
|
12
|
+
def normalize
|
13
|
+
return nil if @query_string.nil?
|
14
|
+
return "" if @query_string.empty?
|
15
|
+
|
16
|
+
normalized = @query_string.dup
|
17
|
+
|
18
|
+
# Step 1: Temporarily protect quoted identifiers
|
19
|
+
protected_identifiers = protect_identifiers(normalized)
|
20
|
+
normalized = protected_identifiers[:normalized]
|
21
|
+
|
22
|
+
# Step 2: Replace literal values
|
23
|
+
normalized = replace_literal_values(normalized)
|
24
|
+
|
25
|
+
# Step 3: Handle special SQL constructs
|
26
|
+
normalized = handle_special_constructs(normalized)
|
27
|
+
|
28
|
+
# Step 4: Restore protected identifiers
|
29
|
+
normalized = restore_identifiers(normalized, protected_identifiers[:mapping])
|
30
|
+
|
31
|
+
# Step 5: Clean up and normalize whitespace
|
32
|
+
normalize_whitespace(normalized)
|
33
|
+
end
|
34
|
+
|
35
|
+
private
|
36
|
+
|
37
|
+
def protect_identifiers(query)
|
38
|
+
protected_identifiers = {}
|
39
|
+
identifier_counter = 0
|
40
|
+
normalized = query.dup
|
41
|
+
|
42
|
+
# Protect backticked identifiers (MySQL style)
|
43
|
+
normalized = normalized.gsub(/`([^`]+)`/) do |match|
|
44
|
+
placeholder = "__IDENTIFIER_#{identifier_counter}__"
|
45
|
+
protected_identifiers[placeholder] = match
|
46
|
+
identifier_counter += 1
|
47
|
+
placeholder
|
48
|
+
end
|
49
|
+
|
50
|
+
# Protect double-quoted identifiers (PostgreSQL/SQL standard style)
|
51
|
+
# Only protect if they appear in contexts where identifiers are expected
|
52
|
+
normalized = normalized.gsub(/"([^"]+)"/) do |match|
|
53
|
+
content = $1
|
54
|
+
# Only protect if it looks like an identifier (no spaces, not a sentence)
|
55
|
+
if looks_like_identifier?(content)
|
56
|
+
placeholder = "__IDENTIFIER_#{identifier_counter}__"
|
57
|
+
protected_identifiers[placeholder] = match
|
58
|
+
identifier_counter += 1
|
59
|
+
placeholder
|
60
|
+
else
|
61
|
+
match # Leave it as-is for now, will be replaced as string literal
|
62
|
+
end
|
63
|
+
end
|
64
|
+
|
65
|
+
{ normalized: normalized, mapping: protected_identifiers }
|
66
|
+
end
|
67
|
+
|
68
|
+
def looks_like_identifier?(content)
|
69
|
+
content.match?(/^[a-zA-Z_][a-zA-Z0-9_]*$/) || content.include?(".")
|
70
|
+
end
|
71
|
+
|
72
|
+
def replace_literal_values(query)
|
73
|
+
normalized = query.dup
|
74
|
+
|
75
|
+
# Replace floating-point numbers FIRST (before integers) to avoid double replacement
|
76
|
+
normalized = normalized.gsub(/(?<![a-zA-Z_])\b\d+\.\d+\b(?![a-zA-Z_])/, "?")
|
77
|
+
|
78
|
+
# Replace integer literals with placeholders, but preserve identifiers containing numbers
|
79
|
+
# Negative lookbehind/lookahead prevents replacing numbers in table/column names
|
80
|
+
normalized = normalized.gsub(/(?<![a-zA-Z_])\b\d+\b(?![a-zA-Z_])/, "?")
|
81
|
+
|
82
|
+
# Replace string literals (single quotes)
|
83
|
+
normalized = normalized.gsub(/'(?:[^']|'')*'/, "?")
|
84
|
+
|
85
|
+
# Replace double-quoted string literals (not protected identifiers)
|
86
|
+
normalized = normalized.gsub(/"(?:[^"]|"")*"/, "?")
|
87
|
+
|
88
|
+
# Handle boolean literals
|
89
|
+
normalized = normalized.gsub(/\b(true|false)\b/i, "?")
|
90
|
+
|
91
|
+
normalized
|
92
|
+
end
|
93
|
+
|
94
|
+
def handle_special_constructs(query)
|
95
|
+
normalized = query.dup
|
96
|
+
|
97
|
+
# Handle IN clauses with multiple values - replace content but preserve structure
|
98
|
+
normalized = normalized.gsub(/\bIN\s*\(\s*([^)]+)\)/i) do |match|
|
99
|
+
content = $1
|
100
|
+
# Count commas to determine number of values
|
101
|
+
value_count = content.split(",").length
|
102
|
+
placeholders = Array.new(value_count, "?").join(", ")
|
103
|
+
"IN (#{placeholders})"
|
104
|
+
end
|
105
|
+
|
106
|
+
# Handle BETWEEN clauses
|
107
|
+
normalized = normalized.gsub(/\bBETWEEN\s+\?\s+AND\s+\?/i, "BETWEEN ? AND ?")
|
108
|
+
|
109
|
+
normalized
|
110
|
+
end
|
111
|
+
|
112
|
+
def restore_identifiers(query, identifier_mapping)
|
113
|
+
normalized = query.dup
|
114
|
+
identifier_mapping.each do |placeholder, original|
|
115
|
+
normalized = normalized.gsub(placeholder, original)
|
116
|
+
end
|
117
|
+
normalized
|
118
|
+
end
|
119
|
+
|
120
|
+
def normalize_whitespace(query)
|
121
|
+
query.gsub(/\s+/, " ").strip
|
122
|
+
end
|
123
|
+
end
|
124
|
+
end
|
@@ -0,0 +1,19 @@
|
|
1
|
+
<%= link_to root_path, class: 'btn sidebar-menu__button' do %>
|
2
|
+
<%= rails_pulse_icon 'layout-dashboard', width: '16' %>
|
3
|
+
<span class="overflow-ellipsis">Dashboard</span>
|
4
|
+
<% end %>
|
5
|
+
|
6
|
+
<%= link_to routes_path, class: 'btn sidebar-menu__button' do %>
|
7
|
+
<%= rails_pulse_icon 'route', width: '16' %>
|
8
|
+
<span class="overflow-ellipsis">Routes</span>
|
9
|
+
<% end %>
|
10
|
+
|
11
|
+
<%= link_to requests_path, class: 'btn sidebar-menu__button' do %>
|
12
|
+
<%= rails_pulse_icon 'audio-lines', width: '16' %>
|
13
|
+
<span class="overflow-ellipsis">Requests</span>
|
14
|
+
<% end %>
|
15
|
+
|
16
|
+
<%= link_to queries_path, class: 'btn sidebar-menu__button' do %>
|
17
|
+
<%= rails_pulse_icon 'database', width: '16' %>
|
18
|
+
<span class="overflow-ellipsis">Queries</span>
|
19
|
+
<% end %>
|