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,87 @@
|
|
1
|
+
module RailsPulse
|
2
|
+
class Operation < RailsPulse::ApplicationRecord
|
3
|
+
self.table_name = "rails_pulse_operations"
|
4
|
+
|
5
|
+
OPERATION_TYPES = %w[
|
6
|
+
sql
|
7
|
+
controller
|
8
|
+
template
|
9
|
+
partial
|
10
|
+
layout
|
11
|
+
collection
|
12
|
+
cache_read
|
13
|
+
cache_write
|
14
|
+
http
|
15
|
+
job
|
16
|
+
mailer
|
17
|
+
storage
|
18
|
+
].freeze
|
19
|
+
|
20
|
+
# Associations
|
21
|
+
belongs_to :request, class_name: "RailsPulse::Request"
|
22
|
+
belongs_to :query, class_name: "RailsPulse::Query", optional: true
|
23
|
+
|
24
|
+
# Validations
|
25
|
+
validates :request_id, presence: true
|
26
|
+
validates :operation_type, presence: true, inclusion: { in: OPERATION_TYPES }
|
27
|
+
validates :label, presence: true
|
28
|
+
validates :occurred_at, presence: true
|
29
|
+
validates :duration, presence: true, numericality: { greater_than_or_equal_to: 0 }
|
30
|
+
|
31
|
+
# Scopes (optional, for convenience)
|
32
|
+
scope :by_type, ->(type) { where(operation_type: type) }
|
33
|
+
|
34
|
+
before_validation :associate_query
|
35
|
+
|
36
|
+
def self.ransackable_attributes(auth_object = nil)
|
37
|
+
%w[id occurred_at label duration start_time average_query_time_ms query_count operation_type]
|
38
|
+
end
|
39
|
+
|
40
|
+
def self.ransackable_associations(auth_object = nil)
|
41
|
+
%w[]
|
42
|
+
end
|
43
|
+
|
44
|
+
ransacker :average_query_time_ms do
|
45
|
+
Arel.sql("COALESCE(AVG(rails_pulse_operations.duration), 0)")
|
46
|
+
end
|
47
|
+
|
48
|
+
ransacker :query_count do
|
49
|
+
Arel.sql("COUNT(rails_pulse_operations.id)")
|
50
|
+
end
|
51
|
+
|
52
|
+
ransacker :occurred_at, formatter: ->(val) {
|
53
|
+
# Handle different time formats for database compatibility
|
54
|
+
case val
|
55
|
+
when Time, DateTime, ActiveSupport::TimeWithZone
|
56
|
+
val.utc.iso8601
|
57
|
+
when String
|
58
|
+
Time.zone.parse(val).utc.iso8601
|
59
|
+
when Integer
|
60
|
+
Time.at(val).utc.iso8601
|
61
|
+
else
|
62
|
+
# Fallback: try to parse as integer timestamp
|
63
|
+
Time.at(val.to_i).utc.iso8601
|
64
|
+
end
|
65
|
+
} do |parent|
|
66
|
+
parent.table[:occurred_at]
|
67
|
+
end
|
68
|
+
|
69
|
+
def to_s
|
70
|
+
id
|
71
|
+
end
|
72
|
+
|
73
|
+
private
|
74
|
+
|
75
|
+
def associate_query
|
76
|
+
return unless operation_type == "sql" && label.present?
|
77
|
+
|
78
|
+
normalized = normalize_query_label(label)
|
79
|
+
self.query = RailsPulse::Query.find_or_create_by(normalized_sql: normalized)
|
80
|
+
end
|
81
|
+
|
82
|
+
# Normalize SQL query using the dedicated service
|
83
|
+
def normalize_query_label(label)
|
84
|
+
RailsPulse::SqlQueryNormalizer.normalize(label)
|
85
|
+
end
|
86
|
+
end
|
87
|
+
end
|
@@ -0,0 +1,52 @@
|
|
1
|
+
module RailsPulse
|
2
|
+
module Queries
|
3
|
+
module Cards
|
4
|
+
class AverageQueryTimes
|
5
|
+
def initialize(query:)
|
6
|
+
@query = query
|
7
|
+
end
|
8
|
+
|
9
|
+
def to_metric_card
|
10
|
+
operations = if @query
|
11
|
+
RailsPulse::Operation.where(query: @query)
|
12
|
+
else
|
13
|
+
RailsPulse::Operation.all
|
14
|
+
end
|
15
|
+
|
16
|
+
# Calculate overall average response time
|
17
|
+
average_query_time = operations.average(:duration)&.round(0) || 0
|
18
|
+
|
19
|
+
# Calculate trend by comparing last 7 days vs previous 7 days
|
20
|
+
last_7_days = 7.days.ago.beginning_of_day
|
21
|
+
previous_7_days = 14.days.ago.beginning_of_day
|
22
|
+
current_period_avg = operations.where("occurred_at >= ?", last_7_days).average(:duration) || 0
|
23
|
+
previous_period_avg = operations.where("occurred_at >= ? AND occurred_at < ?", previous_7_days, last_7_days).average(:duration) || 0
|
24
|
+
|
25
|
+
percentage = previous_period_avg.zero? ? 0 : ((previous_period_avg - current_period_avg) / previous_period_avg * 100).abs.round(1)
|
26
|
+
trend_icon = percentage < 0.1 ? "move-right" : current_period_avg < previous_period_avg ? "trending-down" : "trending-up"
|
27
|
+
trend_amount = previous_period_avg.zero? ? "0%" : "#{percentage}%"
|
28
|
+
|
29
|
+
sparkline_data = operations
|
30
|
+
.group_by_week(:occurred_at, time_zone: "UTC")
|
31
|
+
.average(:duration)
|
32
|
+
.each_with_object({}) do |(date, avg), hash|
|
33
|
+
formatted_date = date.strftime("%b %-d")
|
34
|
+
value = avg&.round(0) || 0
|
35
|
+
hash[formatted_date] = {
|
36
|
+
value: value
|
37
|
+
}
|
38
|
+
end
|
39
|
+
|
40
|
+
{
|
41
|
+
title: "Average Query Time",
|
42
|
+
summary: "#{average_query_time} ms",
|
43
|
+
line_chart_data: sparkline_data,
|
44
|
+
trend_icon: trend_icon,
|
45
|
+
trend_amount: trend_amount,
|
46
|
+
trend_text: "Compared to last week"
|
47
|
+
}
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
@@ -0,0 +1,57 @@
|
|
1
|
+
module RailsPulse
|
2
|
+
module Queries
|
3
|
+
module Cards
|
4
|
+
class ExecutionRate
|
5
|
+
def initialize(query: nil)
|
6
|
+
@query = query
|
7
|
+
end
|
8
|
+
|
9
|
+
def to_metric_card
|
10
|
+
operations = if @query
|
11
|
+
RailsPulse::Operation.where(query: @query)
|
12
|
+
else
|
13
|
+
RailsPulse::Operation.all
|
14
|
+
end
|
15
|
+
|
16
|
+
# Calculate total request count
|
17
|
+
total_request_count = operations.count
|
18
|
+
|
19
|
+
# Calculate trend by comparing last 7 days vs previous 7 days
|
20
|
+
last_7_days = 7.days.ago.beginning_of_day
|
21
|
+
previous_7_days = 14.days.ago.beginning_of_day
|
22
|
+
current_period_count = operations.where("occurred_at >= ?", last_7_days).count
|
23
|
+
previous_period_count = operations.where("occurred_at >= ? AND occurred_at < ?", previous_7_days, last_7_days).count
|
24
|
+
|
25
|
+
percentage = previous_period_count.zero? ? 0 : ((previous_period_count - current_period_count) / previous_period_count.to_f * 100).abs.round(1)
|
26
|
+
trend_icon = percentage < 0.1 ? "move-right" : current_period_count < previous_period_count ? "trending-down" : "trending-up"
|
27
|
+
trend_amount = previous_period_count.zero? ? "0%" : "#{percentage}%"
|
28
|
+
|
29
|
+
sparkline_data = operations
|
30
|
+
.group_by_week(:occurred_at, time_zone: "UTC")
|
31
|
+
.count
|
32
|
+
.each_with_object({}) do |(date, count), hash|
|
33
|
+
formatted_date = date.strftime("%b %-d")
|
34
|
+
hash[formatted_date] = {
|
35
|
+
value: count
|
36
|
+
}
|
37
|
+
end
|
38
|
+
|
39
|
+
# Calculate average operations per minute
|
40
|
+
min_time = operations.minimum(:occurred_at)
|
41
|
+
max_time = operations.maximum(:occurred_at)
|
42
|
+
total_minutes = min_time && max_time && min_time != max_time ? (max_time - min_time) / 60.0 : 1
|
43
|
+
average_operations_per_minute = total_request_count / total_minutes
|
44
|
+
|
45
|
+
{
|
46
|
+
title: "Execution Rate",
|
47
|
+
summary: "#{average_operations_per_minute.round(2)} / min",
|
48
|
+
line_chart_data: sparkline_data,
|
49
|
+
trend_icon: trend_icon,
|
50
|
+
trend_amount: trend_amount,
|
51
|
+
trend_text: "Compared to last week"
|
52
|
+
}
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
@@ -0,0 +1,71 @@
|
|
1
|
+
module RailsPulse
|
2
|
+
module Queries
|
3
|
+
module Cards
|
4
|
+
class PercentileQueryTimes
|
5
|
+
def initialize(query: nil)
|
6
|
+
@query = query
|
7
|
+
end
|
8
|
+
|
9
|
+
def to_metric_card
|
10
|
+
operations = if @query
|
11
|
+
RailsPulse::Operation.where(query: @query)
|
12
|
+
else
|
13
|
+
RailsPulse::Operation.all
|
14
|
+
end
|
15
|
+
|
16
|
+
# Calculate overall 95th percentile response time
|
17
|
+
count = operations.count
|
18
|
+
percentile_95th = if count > 0
|
19
|
+
operations.select("duration").order("duration").limit(1).offset((count * 0.95).floor).pluck(:duration).first || 0
|
20
|
+
else
|
21
|
+
0
|
22
|
+
end
|
23
|
+
|
24
|
+
# Calculate trend by comparing last 7 days vs previous 7 days for 95th percentile
|
25
|
+
last_7_days = 7.days.ago.beginning_of_day
|
26
|
+
previous_7_days = 14.days.ago.beginning_of_day
|
27
|
+
|
28
|
+
current_period = operations.where("occurred_at >= ?", last_7_days)
|
29
|
+
current_count = current_period.count
|
30
|
+
current_period_95th = if current_count > 0
|
31
|
+
current_period.select("duration").order("duration").limit(1).offset((current_count * 0.95).floor).pluck(:duration).first || 0
|
32
|
+
else
|
33
|
+
0
|
34
|
+
end
|
35
|
+
|
36
|
+
previous_period = operations.where("occurred_at >= ? AND occurred_at < ?", previous_7_days, last_7_days)
|
37
|
+
previous_count = previous_period.count
|
38
|
+
previous_period_95th = if previous_count > 0
|
39
|
+
previous_period.select("duration").order("duration").limit(1).offset((previous_count * 0.95).floor).pluck(:duration).first || 0
|
40
|
+
else
|
41
|
+
0
|
42
|
+
end
|
43
|
+
|
44
|
+
percentage = previous_period_95th.zero? ? 0 : ((previous_period_95th - current_period_95th) / previous_period_95th * 100).abs.round(1)
|
45
|
+
trend_icon = percentage < 0.1 ? "move-right" : current_period_95th < previous_period_95th ? "trending-down" : "trending-up"
|
46
|
+
trend_amount = previous_period_95th.zero? ? "0%" : "#{percentage}%"
|
47
|
+
|
48
|
+
sparkline_data = operations
|
49
|
+
.group_by_week(:occurred_at, time_zone: "UTC")
|
50
|
+
.average(:duration)
|
51
|
+
.each_with_object({}) do |(date, avg), hash|
|
52
|
+
formatted_date = date.strftime("%b %-d")
|
53
|
+
value = avg&.round(0) || 0
|
54
|
+
hash[formatted_date] = {
|
55
|
+
value: value
|
56
|
+
}
|
57
|
+
end
|
58
|
+
|
59
|
+
{
|
60
|
+
title: "95th Percentile Query Time",
|
61
|
+
summary: "#{percentile_95th} ms",
|
62
|
+
line_chart_data: sparkline_data,
|
63
|
+
trend_icon: trend_icon,
|
64
|
+
trend_amount: trend_amount,
|
65
|
+
trend_text: "Compared to last week"
|
66
|
+
}
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
@@ -0,0 +1,112 @@
|
|
1
|
+
module RailsPulse
|
2
|
+
module Queries
|
3
|
+
module Charts
|
4
|
+
class AverageQueryTimes
|
5
|
+
def initialize(ransack_query:, group_by: :group_by_day, query: nil)
|
6
|
+
@ransack_query = ransack_query
|
7
|
+
@group_by = group_by
|
8
|
+
@query = query
|
9
|
+
end
|
10
|
+
|
11
|
+
def to_rails_chart
|
12
|
+
# Get actual data using existing logic
|
13
|
+
actual_data = if @query
|
14
|
+
@ransack_query.result(distinct: false)
|
15
|
+
.public_send(@group_by, "occurred_at", series: true, time_zone: "UTC")
|
16
|
+
.average(:duration)
|
17
|
+
else
|
18
|
+
@ransack_query.result(distinct: false)
|
19
|
+
.left_joins(:operations)
|
20
|
+
.public_send(
|
21
|
+
@group_by,
|
22
|
+
"rails_pulse_operations.occurred_at",
|
23
|
+
series: true,
|
24
|
+
time_zone: "UTC"
|
25
|
+
)
|
26
|
+
.average("rails_pulse_operations.duration")
|
27
|
+
end
|
28
|
+
|
29
|
+
# Create full time range and fill in missing periods
|
30
|
+
fill_missing_periods(actual_data)
|
31
|
+
end
|
32
|
+
|
33
|
+
private
|
34
|
+
|
35
|
+
def fill_missing_periods(actual_data)
|
36
|
+
# Extract actual time range from ransack query conditions
|
37
|
+
start_time, end_time = extract_time_range_from_ransack
|
38
|
+
|
39
|
+
# Create time range based on grouping type
|
40
|
+
case @group_by
|
41
|
+
when :group_by_hour
|
42
|
+
time_range = generate_hour_range(start_time, end_time)
|
43
|
+
else # :group_by_day
|
44
|
+
time_range = generate_day_range(start_time, end_time)
|
45
|
+
end
|
46
|
+
|
47
|
+
# Fill in all periods with zero values for missing periods
|
48
|
+
time_range.each_with_object({}) do |period, result|
|
49
|
+
occurred_at = period.is_a?(String) ? Time.parse(period) : period
|
50
|
+
occurred_at = (occurred_at.is_a?(Time) || occurred_at.is_a?(Date)) ? occurred_at : Time.current
|
51
|
+
|
52
|
+
normalized_occurred_at =
|
53
|
+
case @group_by
|
54
|
+
when :group_by_hour
|
55
|
+
occurred_at&.beginning_of_hour || occurred_at
|
56
|
+
when :group_by_day
|
57
|
+
occurred_at&.beginning_of_day || occurred_at
|
58
|
+
else
|
59
|
+
occurred_at
|
60
|
+
end
|
61
|
+
|
62
|
+
# Use actual data if available, otherwise default to 0
|
63
|
+
average_duration = actual_data[period] || 0
|
64
|
+
result[normalized_occurred_at.to_i] = {
|
65
|
+
value: average_duration.to_f
|
66
|
+
}
|
67
|
+
end
|
68
|
+
end
|
69
|
+
|
70
|
+
def generate_day_range(start_time, end_time)
|
71
|
+
(start_time.to_date..end_time.to_date).map(&:beginning_of_day)
|
72
|
+
end
|
73
|
+
|
74
|
+
def generate_hour_range(start_time, end_time)
|
75
|
+
current = start_time
|
76
|
+
hours = []
|
77
|
+
while current <= end_time
|
78
|
+
hours << current
|
79
|
+
current += 1.hour
|
80
|
+
end
|
81
|
+
hours
|
82
|
+
end
|
83
|
+
|
84
|
+
def extract_time_range_from_ransack
|
85
|
+
# Extract time range from ransack conditions
|
86
|
+
conditions = @ransack_query.conditions
|
87
|
+
|
88
|
+
if @query
|
89
|
+
# For specific query, look for occurred_at conditions
|
90
|
+
start_condition = conditions.find { |c| c.a.first == "occurred_at" && c.p == "gteq" }
|
91
|
+
end_condition = conditions.find { |c| c.a.first == "occurred_at" && c.p == "lt" }
|
92
|
+
else
|
93
|
+
# For general operations, look for rails_pulse_operations_occurred_at conditions
|
94
|
+
start_condition = conditions.find { |c| c.a.first == "rails_pulse_operations_occurred_at" && c.p == "gteq" }
|
95
|
+
end_condition = conditions.find { |c| c.a.first == "rails_pulse_operations_occurred_at" && c.p == "lt" }
|
96
|
+
end
|
97
|
+
|
98
|
+
start_time = start_condition&.v || 2.weeks.ago
|
99
|
+
end_time = end_condition&.v || Time.current
|
100
|
+
|
101
|
+
# Normalize time boundaries based on grouping
|
102
|
+
case @group_by
|
103
|
+
when :group_by_hour
|
104
|
+
[ start_time.beginning_of_hour, end_time.beginning_of_hour ]
|
105
|
+
else
|
106
|
+
[ start_time.beginning_of_day, end_time.beginning_of_day ]
|
107
|
+
end
|
108
|
+
end
|
109
|
+
end
|
110
|
+
end
|
111
|
+
end
|
112
|
+
end
|
@@ -0,0 +1,58 @@
|
|
1
|
+
module RailsPulse
|
2
|
+
class Query < RailsPulse::ApplicationRecord
|
3
|
+
self.table_name = "rails_pulse_queries"
|
4
|
+
|
5
|
+
# Associations
|
6
|
+
has_many :operations, class_name: "RailsPulse::Operation", inverse_of: :query
|
7
|
+
|
8
|
+
# Validations
|
9
|
+
validates :normalized_sql, presence: true, uniqueness: true
|
10
|
+
|
11
|
+
def self.ransackable_attributes(auth_object = nil)
|
12
|
+
%w[id normalized_sql average_query_time_ms execution_count total_time_consumed performance_status occurred_at]
|
13
|
+
end
|
14
|
+
|
15
|
+
def self.ransackable_associations(auth_object = nil)
|
16
|
+
%w[operations]
|
17
|
+
end
|
18
|
+
|
19
|
+
ransacker :average_query_time_ms do
|
20
|
+
Arel.sql("COALESCE(AVG(rails_pulse_operations.duration), 0)")
|
21
|
+
end
|
22
|
+
|
23
|
+
ransacker :execution_count do
|
24
|
+
Arel.sql("COUNT(rails_pulse_operations.id)")
|
25
|
+
end
|
26
|
+
|
27
|
+
ransacker :total_time_consumed do
|
28
|
+
Arel.sql("COALESCE(SUM(rails_pulse_operations.duration), 0)")
|
29
|
+
end
|
30
|
+
|
31
|
+
ransacker :performance_status do
|
32
|
+
# Calculate status indicator based on query_thresholds with safe defaults
|
33
|
+
config = RailsPulse.configuration rescue nil
|
34
|
+
thresholds = config&.query_thresholds || { slow: 200, very_slow: 500, critical: 1000 }
|
35
|
+
|
36
|
+
slow = (thresholds[:slow] || 200).to_f
|
37
|
+
very_slow = (thresholds[:very_slow] || 500).to_f
|
38
|
+
critical = (thresholds[:critical] || 1000).to_f
|
39
|
+
|
40
|
+
# Use Arel to safely construct the SQL with parameterized values
|
41
|
+
avg_duration = Arel.sql("COALESCE(AVG(rails_pulse_operations.duration), 0)")
|
42
|
+
|
43
|
+
Arel::Nodes::Case.new(avg_duration)
|
44
|
+
.when(avg_duration.lt(slow)).then(0)
|
45
|
+
.when(avg_duration.lt(very_slow)).then(1)
|
46
|
+
.when(avg_duration.lt(critical)).then(2)
|
47
|
+
.else(3)
|
48
|
+
end
|
49
|
+
|
50
|
+
ransacker :occurred_at do
|
51
|
+
Arel.sql("MAX(rails_pulse_operations.occurred_at)")
|
52
|
+
end
|
53
|
+
|
54
|
+
def to_s
|
55
|
+
id
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
@@ -0,0 +1,64 @@
|
|
1
|
+
module RailsPulse
|
2
|
+
class Request < RailsPulse::ApplicationRecord
|
3
|
+
self.table_name = "rails_pulse_requests"
|
4
|
+
|
5
|
+
# Associations
|
6
|
+
belongs_to :route, class_name: "RailsPulse::Route"
|
7
|
+
has_many :operations, class_name: "RailsPulse::Operation", foreign_key: "request_id", dependent: :destroy
|
8
|
+
|
9
|
+
# Validations
|
10
|
+
validates :route_id, presence: true
|
11
|
+
validates :occurred_at, presence: true
|
12
|
+
validates :duration, presence: true, numericality: { greater_than_or_equal_to: 0 }
|
13
|
+
validates :status, presence: true
|
14
|
+
validates :is_error, inclusion: { in: [ true, false ] }
|
15
|
+
validates :request_uuid, presence: true, uniqueness: true
|
16
|
+
|
17
|
+
before_create :set_request_uuid
|
18
|
+
|
19
|
+
def self.ransackable_attributes(auth_object = nil)
|
20
|
+
%w[id route_id occurred_at duration status status_indicator route_path]
|
21
|
+
end
|
22
|
+
|
23
|
+
def self.ransackable_associations(auth_object = nil)
|
24
|
+
%w[route]
|
25
|
+
end
|
26
|
+
|
27
|
+
ransacker :occurred_at do |parent|
|
28
|
+
parent.table[:occurred_at]
|
29
|
+
end
|
30
|
+
|
31
|
+
ransacker :route_path do |parent|
|
32
|
+
Arel.sql("rails_pulse_routes.path")
|
33
|
+
end
|
34
|
+
|
35
|
+
ransacker :status_indicator do |parent|
|
36
|
+
# Calculate status indicator based on request_thresholds with safe defaults
|
37
|
+
config = RailsPulse.configuration rescue nil
|
38
|
+
thresholds = config&.request_thresholds || { slow: 500, very_slow: 1000, critical: 2000 }
|
39
|
+
|
40
|
+
slow = thresholds[:slow] || 500
|
41
|
+
very_slow = thresholds[:very_slow] || 1000
|
42
|
+
critical = thresholds[:critical] || 2000
|
43
|
+
|
44
|
+
Arel.sql("
|
45
|
+
CASE
|
46
|
+
WHEN rails_pulse_requests.duration < #{slow} THEN 0
|
47
|
+
WHEN rails_pulse_requests.duration < #{very_slow} THEN 1
|
48
|
+
WHEN rails_pulse_requests.duration < #{critical} THEN 2
|
49
|
+
ELSE 3
|
50
|
+
END
|
51
|
+
")
|
52
|
+
end
|
53
|
+
|
54
|
+
def to_s
|
55
|
+
occurred_at.strftime("%b %d, %Y %l:%M %p")
|
56
|
+
end
|
57
|
+
|
58
|
+
private
|
59
|
+
|
60
|
+
def set_request_uuid
|
61
|
+
self.request_uuid = SecureRandom.uuid if request_uuid.blank?
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
@@ -0,0 +1,99 @@
|
|
1
|
+
module RailsPulse
|
2
|
+
module Requests
|
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 = @ransack_query.result(distinct: false)
|
14
|
+
.public_send(
|
15
|
+
@group_by,
|
16
|
+
"rails_pulse_requests.occurred_at",
|
17
|
+
series: true,
|
18
|
+
time_zone: "UTC"
|
19
|
+
)
|
20
|
+
.average("rails_pulse_requests.duration")
|
21
|
+
|
22
|
+
# Create full time range and fill in missing periods
|
23
|
+
fill_missing_periods(actual_data)
|
24
|
+
end
|
25
|
+
|
26
|
+
private
|
27
|
+
|
28
|
+
def fill_missing_periods(actual_data)
|
29
|
+
# Extract actual time range from ransack query conditions
|
30
|
+
start_time, end_time = extract_time_range_from_ransack
|
31
|
+
|
32
|
+
# Create time range based on grouping type
|
33
|
+
case @group_by
|
34
|
+
when :group_by_hour
|
35
|
+
time_range = generate_hour_range(start_time, end_time)
|
36
|
+
else # :group_by_day
|
37
|
+
time_range = generate_day_range(start_time, end_time)
|
38
|
+
end
|
39
|
+
|
40
|
+
# Fill in all periods with zero values for missing periods
|
41
|
+
time_range.each_with_object({}) do |period, result|
|
42
|
+
occurred_at = period.is_a?(String) ? Time.parse(period) : period
|
43
|
+
occurred_at = (occurred_at.is_a?(Time) || occurred_at.is_a?(Date)) ? occurred_at : Time.current
|
44
|
+
|
45
|
+
normalized_occurred_at =
|
46
|
+
case @group_by
|
47
|
+
when :group_by_hour
|
48
|
+
occurred_at&.beginning_of_hour || occurred_at
|
49
|
+
when :group_by_day
|
50
|
+
occurred_at&.beginning_of_day || occurred_at
|
51
|
+
else
|
52
|
+
occurred_at
|
53
|
+
end
|
54
|
+
|
55
|
+
# Use actual data if available, otherwise default to 0
|
56
|
+
average_duration = actual_data[period] || 0
|
57
|
+
result[normalized_occurred_at.to_i] = {
|
58
|
+
value: average_duration.to_f
|
59
|
+
}
|
60
|
+
end
|
61
|
+
end
|
62
|
+
|
63
|
+
def generate_day_range(start_time, end_time)
|
64
|
+
(start_time.to_date..end_time.to_date).map(&:beginning_of_day)
|
65
|
+
end
|
66
|
+
|
67
|
+
def generate_hour_range(start_time, end_time)
|
68
|
+
current = start_time
|
69
|
+
hours = []
|
70
|
+
while current <= end_time
|
71
|
+
hours << current
|
72
|
+
current += 1.hour
|
73
|
+
end
|
74
|
+
hours
|
75
|
+
end
|
76
|
+
|
77
|
+
def extract_time_range_from_ransack
|
78
|
+
# Extract time range from ransack conditions
|
79
|
+
conditions = @ransack_query.conditions
|
80
|
+
|
81
|
+
# For requests, look for occurred_at conditions on rails_pulse_requests
|
82
|
+
start_condition = conditions.find { |c| c.a.first == "rails_pulse_requests_occurred_at" && c.p == "gteq" }
|
83
|
+
end_condition = conditions.find { |c| c.a.first == "rails_pulse_requests_occurred_at" && c.p == "lt" }
|
84
|
+
|
85
|
+
start_time = start_condition&.v || 2.weeks.ago
|
86
|
+
end_time = end_condition&.v || Time.current
|
87
|
+
|
88
|
+
# Normalize time boundaries based on grouping
|
89
|
+
case @group_by
|
90
|
+
when :group_by_hour
|
91
|
+
[ start_time.beginning_of_hour, end_time.beginning_of_hour ]
|
92
|
+
else
|
93
|
+
[ start_time.beginning_of_day, end_time.beginning_of_day ]
|
94
|
+
end
|
95
|
+
end
|
96
|
+
end
|
97
|
+
end
|
98
|
+
end
|
99
|
+
end
|
@@ -0,0 +1,35 @@
|
|
1
|
+
module RailsPulse
|
2
|
+
module Requests
|
3
|
+
module Charts
|
4
|
+
class OperationsChart
|
5
|
+
OperationBar = Struct.new(:operation, :duration, :left_pct, :width_pct)
|
6
|
+
|
7
|
+
attr_reader :bars, :min_start, :max_end, :total_duration
|
8
|
+
|
9
|
+
HORIZONTAL_OFFSET_PX = 20
|
10
|
+
|
11
|
+
def initialize(operations)
|
12
|
+
@operations = operations
|
13
|
+
@min_start = @operations.map(&:start_time).min || 0
|
14
|
+
@max_end = @operations.map { |op| op.start_time + op.duration }.max || 1
|
15
|
+
@total_duration = (@max_end - @min_start).nonzero? || 1
|
16
|
+
@bars = build_bars
|
17
|
+
end
|
18
|
+
|
19
|
+
private
|
20
|
+
|
21
|
+
def build_bars
|
22
|
+
@operations.map do |operation|
|
23
|
+
left_pct = ((operation.start_time - @min_start).to_f / @total_duration) * (100 - px_to_pct) + px_to_pct / 2
|
24
|
+
width_pct = (operation.duration.to_f / @total_duration) * (100 - px_to_pct)
|
25
|
+
OperationBar.new(operation, operation.duration.round(0), left_pct, width_pct)
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
def px_to_pct
|
30
|
+
(HORIZONTAL_OFFSET_PX.to_f / 1000) * 100
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|