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,37 @@
|
|
1
|
+
:where(.table) {
|
2
|
+
caption-side: bottom;
|
3
|
+
font-size: var(--text-sm);
|
4
|
+
inline-size: var(--size-full);
|
5
|
+
|
6
|
+
caption {
|
7
|
+
color: var(--color-text-subtle);
|
8
|
+
margin-block-start: var(--size-4);
|
9
|
+
}
|
10
|
+
|
11
|
+
thead {
|
12
|
+
color: var(--color-text-subtle);
|
13
|
+
}
|
14
|
+
|
15
|
+
tbody tr {
|
16
|
+
border-block-start-width: var(--border);
|
17
|
+
}
|
18
|
+
|
19
|
+
tr:hover {
|
20
|
+
background-color: rgb(from var(--color-border-light) r g b / .5);
|
21
|
+
}
|
22
|
+
|
23
|
+
th {
|
24
|
+
font-weight: var(--font-medium);
|
25
|
+
text-align: start;
|
26
|
+
}
|
27
|
+
|
28
|
+
th, td {
|
29
|
+
padding: var(--size-2);
|
30
|
+
}
|
31
|
+
|
32
|
+
tfoot {
|
33
|
+
background-color: rgb(from var(--color-border-light) r g b / .5);
|
34
|
+
border-block-start-width: var(--border);
|
35
|
+
font-weight: var(--font-medium);
|
36
|
+
}
|
37
|
+
}
|
@@ -0,0 +1,36 @@
|
|
1
|
+
/* Width utilities */
|
2
|
+
.w-auto { width: auto; }
|
3
|
+
.w-4 { width: 1rem; }
|
4
|
+
.w-6 { width: 1.5rem; }
|
5
|
+
.w-8 { width: 2rem; }
|
6
|
+
.w-12 { width: 3rem; }
|
7
|
+
.w-16 { width: 4rem; }
|
8
|
+
.w-20 { width: 5rem; }
|
9
|
+
.w-24 { width: 6rem; }
|
10
|
+
.w-28 { width: 7rem; }
|
11
|
+
.w-32 { width: 8rem; }
|
12
|
+
.w-36 { width: 9rem; }
|
13
|
+
.w-40 { width: 10rem; }
|
14
|
+
.w-44 { width: 11rem; }
|
15
|
+
.w-48 { width: 12rem; }
|
16
|
+
.w-52 { width: 13rem; }
|
17
|
+
.w-56 { width: 14rem; }
|
18
|
+
.w-60 { width: 15rem; }
|
19
|
+
.w-64 { width: 16rem; }
|
20
|
+
|
21
|
+
/* Min-width utilities */
|
22
|
+
.min-w-0 { min-width: 0; }
|
23
|
+
.min-w-4 { min-width: 1rem; }
|
24
|
+
.min-w-8 { min-width: 2rem; }
|
25
|
+
.min-w-12 { min-width: 3rem; }
|
26
|
+
.min-w-16 { min-width: 4rem; }
|
27
|
+
.min-w-20 { min-width: 5rem; }
|
28
|
+
.min-w-24 { min-width: 6rem; }
|
29
|
+
.min-w-32 { min-width: 8rem; }
|
30
|
+
|
31
|
+
/* Max-width utilities */
|
32
|
+
.max-w-xs { max-width: 20rem; }
|
33
|
+
.max-w-sm { max-width: 24rem; }
|
34
|
+
.max-w-md { max-width: 28rem; }
|
35
|
+
.max-w-lg { max-width: 32rem; }
|
36
|
+
.max-w-xl { max-width: 36rem; }
|
@@ -0,0 +1,82 @@
|
|
1
|
+
module ChartTableConcern
|
2
|
+
extend ActiveSupport::Concern
|
3
|
+
|
4
|
+
included do
|
5
|
+
include Pagy::Backend
|
6
|
+
include TimeRangeConcern
|
7
|
+
include ResponseRangeConcern
|
8
|
+
include ZoomRangeConcern
|
9
|
+
|
10
|
+
before_action :setup_time_and_response_ranges
|
11
|
+
before_action :setup_zoom_range_data
|
12
|
+
end
|
13
|
+
|
14
|
+
private
|
15
|
+
|
16
|
+
def setup_chart_and_table_data
|
17
|
+
ransack_params = params[:q] || {}
|
18
|
+
|
19
|
+
# Setup chart data first using original time range (no sorting from table)
|
20
|
+
unless turbo_frame_request?
|
21
|
+
setup_chart_formatters
|
22
|
+
setup_chart_data(ransack_params)
|
23
|
+
end
|
24
|
+
|
25
|
+
# Setup table data using zoom parameters if present, otherwise use chart parameters
|
26
|
+
setup_table_data(ransack_params)
|
27
|
+
end
|
28
|
+
|
29
|
+
def setup_chart_data(ransack_params)
|
30
|
+
chart_ransack_params = build_chart_ransack_params(ransack_params)
|
31
|
+
chart_ransack_query = chart_model.ransack(chart_ransack_params)
|
32
|
+
@chart_data = chart_class.new(
|
33
|
+
ransack_query: chart_ransack_query,
|
34
|
+
group_by: group_by,
|
35
|
+
**chart_options
|
36
|
+
).to_rails_chart
|
37
|
+
end
|
38
|
+
|
39
|
+
def setup_table_data(ransack_params)
|
40
|
+
table_ransack_params = build_table_ransack_params(ransack_params)
|
41
|
+
@ransack_query = table_model.ransack(table_ransack_params)
|
42
|
+
@ransack_query.sorts = default_table_sort if @ransack_query.sorts.empty?
|
43
|
+
|
44
|
+
table_results = build_table_results
|
45
|
+
handle_pagination
|
46
|
+
@pagy, @table_data = pagy(table_results, limit: session_pagination_limit)
|
47
|
+
end
|
48
|
+
|
49
|
+
def setup_zoom_range_data
|
50
|
+
@zoom_start, @zoom_end, @table_start_time, @table_end_time = setup_zoom_range(@start_time, @end_time)
|
51
|
+
end
|
52
|
+
|
53
|
+
def setup_time_and_response_ranges
|
54
|
+
@start_time, @end_time, @selected_time_range, @time_diff_hours = setup_time_range
|
55
|
+
@start_duration, @selected_response_range = setup_duration_range
|
56
|
+
end
|
57
|
+
|
58
|
+
def setup_chart_formatters
|
59
|
+
@xaxis_formatter = RailsPulse::ChartFormatters.occurred_at_as_time_or_date(@time_diff_hours)
|
60
|
+
@tooltip_formatter = RailsPulse::ChartFormatters.tooltip_as_time_or_date_with_marker(@time_diff_hours)
|
61
|
+
end
|
62
|
+
|
63
|
+
def group_by
|
64
|
+
@time_diff_hours <= 25 ? :group_by_hour : :group_by_day
|
65
|
+
end
|
66
|
+
|
67
|
+
def handle_pagination
|
68
|
+
method = pagination_method
|
69
|
+
send(method, params[:limit]) if params[:limit].present?
|
70
|
+
end
|
71
|
+
|
72
|
+
# Abstract methods - must be implemented by including controllers
|
73
|
+
def chart_model; raise NotImplementedError; end
|
74
|
+
def table_model; raise NotImplementedError; end
|
75
|
+
def chart_class; raise NotImplementedError; end
|
76
|
+
def chart_options; {}; end
|
77
|
+
def build_chart_ransack_params(ransack_params); raise NotImplementedError; end
|
78
|
+
def build_table_ransack_params(ransack_params); raise NotImplementedError; end
|
79
|
+
def default_table_sort; raise NotImplementedError; end
|
80
|
+
def build_table_results; raise NotImplementedError; end
|
81
|
+
def pagination_method; :store_pagination_limit; end
|
82
|
+
end
|
@@ -0,0 +1,24 @@
|
|
1
|
+
module ResponseRangeConcern
|
2
|
+
extend ActiveSupport::Concern
|
3
|
+
|
4
|
+
def setup_duration_range(type = :route)
|
5
|
+
ransack_params = params[:q] || {}
|
6
|
+
thresholds = RailsPulse.configuration.public_send("#{type}_thresholds")
|
7
|
+
|
8
|
+
if ransack_params[:duration].present?
|
9
|
+
selected_range = ransack_params[:duration]
|
10
|
+
start_duration =
|
11
|
+
case ransack_params[:duration].to_sym
|
12
|
+
when :slow then thresholds[:slow]
|
13
|
+
when :very_slow then thresholds[:very_slow]
|
14
|
+
when :critical then thresholds[:critical]
|
15
|
+
else 0
|
16
|
+
end
|
17
|
+
else
|
18
|
+
start_duration = 0
|
19
|
+
selected_range = :all
|
20
|
+
end
|
21
|
+
|
22
|
+
[ start_duration, selected_range ]
|
23
|
+
end
|
24
|
+
end
|
@@ -0,0 +1,67 @@
|
|
1
|
+
module TimeRangeConcern
|
2
|
+
extend ActiveSupport::Concern
|
3
|
+
|
4
|
+
included do
|
5
|
+
# Define the constant in the including class - ordered by most common usage
|
6
|
+
const_set(:TIME_RANGE_OPTIONS, [
|
7
|
+
[ "Last 24 hours", :last_day ],
|
8
|
+
[ "Last Week", :last_week ],
|
9
|
+
[ "Last Month", :last_month ],
|
10
|
+
[ "All Time", :all_time ]
|
11
|
+
].freeze)
|
12
|
+
end
|
13
|
+
|
14
|
+
def setup_time_range
|
15
|
+
start_time = 1.day.ago
|
16
|
+
end_time = Time.zone.now
|
17
|
+
selected_time_range = :last_day
|
18
|
+
|
19
|
+
ransack_params = params[:q] || {}
|
20
|
+
|
21
|
+
if ransack_params[:requests_occurred_at_gteq].present?
|
22
|
+
# Custom time range from routes index chart zoom which filters requests through an association
|
23
|
+
start_time = parse_time_param(ransack_params[:requests_occurred_at_gteq])
|
24
|
+
end_time = parse_time_param(ransack_params[:requests_occurred_at_lt])
|
25
|
+
elsif ransack_params[:occurred_at_gteq].present?
|
26
|
+
# Custom time range from chart zoom where there is no association
|
27
|
+
start_time = parse_time_param(ransack_params[:occurred_at_gteq])
|
28
|
+
end_time = parse_time_param(ransack_params[:occurred_at_lt])
|
29
|
+
elsif ransack_params[:occurred_at_range]
|
30
|
+
# Predefined time range from dropdown
|
31
|
+
selected_time_range = ransack_params[:occurred_at_range]
|
32
|
+
start_time =
|
33
|
+
case selected_time_range.to_sym
|
34
|
+
when :last_day then 1.day.ago
|
35
|
+
when :last_week then 1.week.ago
|
36
|
+
when :last_month then 1.month.ago
|
37
|
+
when :all_time then 100.years.ago
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
time_diff = (end_time.to_i - start_time.to_i) / 3600.0
|
42
|
+
|
43
|
+
if time_diff <= 25
|
44
|
+
start_time = start_time.beginning_of_hour
|
45
|
+
end_time = end_time.end_of_hour
|
46
|
+
else
|
47
|
+
start_time = start_time.beginning_of_day
|
48
|
+
end_time = end_time.end_of_day
|
49
|
+
end
|
50
|
+
|
51
|
+
[ start_time, end_time, selected_time_range, time_diff ]
|
52
|
+
end
|
53
|
+
|
54
|
+
private
|
55
|
+
|
56
|
+
def parse_time_param(param)
|
57
|
+
case param
|
58
|
+
when Time, DateTime
|
59
|
+
param.in_time_zone
|
60
|
+
when String
|
61
|
+
Time.zone.parse(param)
|
62
|
+
else
|
63
|
+
# Assume it's an integer timestamp
|
64
|
+
Time.zone.at(param.to_i)
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
@@ -0,0 +1,40 @@
|
|
1
|
+
module ZoomRangeConcern
|
2
|
+
extend ActiveSupport::Concern
|
3
|
+
|
4
|
+
def setup_zoom_range(main_start_time, main_end_time)
|
5
|
+
# Extract zoom parameters from params (this removes them from params)
|
6
|
+
zoom_start = params.delete(:zoom_start_time)
|
7
|
+
zoom_end = params.delete(:zoom_end_time)
|
8
|
+
|
9
|
+
# Normalize zoom times to beginning/end of day or hour like we do for main time range
|
10
|
+
if zoom_start && zoom_end
|
11
|
+
zoom_start, zoom_end = normalize_zoom_times(zoom_start.to_i, zoom_end.to_i)
|
12
|
+
end
|
13
|
+
|
14
|
+
# Calculate table times - use zoom if present, otherwise fallback to main times
|
15
|
+
table_start_time = zoom_start || main_start_time
|
16
|
+
table_end_time = zoom_end || main_end_time
|
17
|
+
|
18
|
+
[ zoom_start, zoom_end, table_start_time, table_end_time ]
|
19
|
+
end
|
20
|
+
|
21
|
+
private
|
22
|
+
|
23
|
+
def normalize_zoom_times(start_time, end_time)
|
24
|
+
time_diff = (end_time - start_time) / 3600.0
|
25
|
+
|
26
|
+
if time_diff <= 25
|
27
|
+
start_time_obj = Time.zone&.at(start_time) || Time.at(start_time)
|
28
|
+
end_time_obj = Time.zone&.at(end_time) || Time.at(end_time)
|
29
|
+
start_time = start_time_obj&.beginning_of_hour || start_time_obj
|
30
|
+
end_time = end_time_obj&.end_of_hour || end_time_obj
|
31
|
+
else
|
32
|
+
start_time_obj = Time.zone&.at(start_time) || Time.at(start_time)
|
33
|
+
end_time_obj = Time.zone&.at(end_time) || Time.at(end_time)
|
34
|
+
start_time = start_time_obj&.beginning_of_day || start_time_obj
|
35
|
+
end_time = end_time_obj&.end_of_day || end_time_obj
|
36
|
+
end
|
37
|
+
|
38
|
+
[ start_time, end_time ]
|
39
|
+
end
|
40
|
+
end
|
@@ -0,0 +1,67 @@
|
|
1
|
+
module RailsPulse
|
2
|
+
class ApplicationController < ActionController::Base
|
3
|
+
before_action :authenticate_rails_pulse_user!
|
4
|
+
|
5
|
+
def set_pagination_limit
|
6
|
+
session[:pagination_limit] = params[:limit].to_i if params[:limit].present?
|
7
|
+
render json: { status: "ok" }
|
8
|
+
end
|
9
|
+
|
10
|
+
private
|
11
|
+
|
12
|
+
def authenticate_rails_pulse_user!
|
13
|
+
return unless RailsPulse.configuration.authentication_enabled
|
14
|
+
|
15
|
+
# If no authentication method is configured, use fallback HTTP Basic Auth
|
16
|
+
if RailsPulse.configuration.authentication_method.nil?
|
17
|
+
return fallback_http_basic_auth
|
18
|
+
end
|
19
|
+
|
20
|
+
# Safely execute authentication method in controller context
|
21
|
+
case RailsPulse.configuration.authentication_method
|
22
|
+
when Proc
|
23
|
+
instance_exec(&RailsPulse.configuration.authentication_method)
|
24
|
+
when Symbol, String
|
25
|
+
method_name = RailsPulse.configuration.authentication_method.to_s
|
26
|
+
if respond_to?(method_name, true)
|
27
|
+
send(method_name)
|
28
|
+
else
|
29
|
+
Rails.logger.error "RailsPulse: Authentication method '#{method_name}' not found"
|
30
|
+
render plain: "Authentication configuration error", status: :internal_server_error
|
31
|
+
end
|
32
|
+
else
|
33
|
+
Rails.logger.error "RailsPulse: Invalid authentication method type: #{RailsPulse.configuration.authentication_method.class}"
|
34
|
+
render plain: "Authentication configuration error", status: :internal_server_error
|
35
|
+
end
|
36
|
+
rescue StandardError => e
|
37
|
+
Rails.logger.warn "RailsPulse authentication failed: #{e.message}"
|
38
|
+
redirect_to RailsPulse.configuration.authentication_redirect_path
|
39
|
+
end
|
40
|
+
|
41
|
+
def fallback_http_basic_auth
|
42
|
+
authenticate_or_request_with_http_basic("Rails Pulse") do |username, password|
|
43
|
+
# Use environment variables for default credentials
|
44
|
+
expected_username = ENV.fetch("RAILS_PULSE_USERNAME", "admin")
|
45
|
+
expected_password = ENV.fetch("RAILS_PULSE_PASSWORD", nil)
|
46
|
+
|
47
|
+
if expected_password.nil?
|
48
|
+
Rails.logger.error "RailsPulse: No authentication method configured and RAILS_PULSE_PASSWORD not set. Access denied."
|
49
|
+
false
|
50
|
+
else
|
51
|
+
username == expected_username && password == expected_password
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
def session_pagination_limit
|
57
|
+
# Keep default small for optimal performance
|
58
|
+
session[:pagination_limit] || 10
|
59
|
+
end
|
60
|
+
|
61
|
+
def store_pagination_limit(limit)
|
62
|
+
# Validate pagination limit: minimum 5, maximum 50 for performance
|
63
|
+
validated_limit = limit.to_i.clamp(5, 50)
|
64
|
+
session[:pagination_limit] = validated_limit if limit.present?
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
@@ -0,0 +1,33 @@
|
|
1
|
+
module RailsPulse
|
2
|
+
class AssetsController < ApplicationController
|
3
|
+
skip_before_action :verify_authenticity_token, only: [ :show ]
|
4
|
+
|
5
|
+
def show
|
6
|
+
asset_name = params[:asset_name]
|
7
|
+
asset_path = Rails.root.join("public", "rails-pulse-assets", asset_name)
|
8
|
+
|
9
|
+
# Fallback to engine assets if not found in host app
|
10
|
+
unless File.exist?(asset_path)
|
11
|
+
asset_path = RailsPulse::Engine.root.join("public", "rails-pulse-assets", asset_name)
|
12
|
+
end
|
13
|
+
|
14
|
+
if File.exist?(asset_path)
|
15
|
+
content_type = case File.extname(asset_name)
|
16
|
+
when ".js" then "application/javascript"
|
17
|
+
when ".css" then "text/css"
|
18
|
+
when ".map" then "application/json"
|
19
|
+
when ".svg" then "image/svg+xml"
|
20
|
+
else "application/octet-stream"
|
21
|
+
end
|
22
|
+
|
23
|
+
send_file asset_path,
|
24
|
+
type: content_type,
|
25
|
+
disposition: "inline",
|
26
|
+
cache: true,
|
27
|
+
expires: 1.year.from_now
|
28
|
+
else
|
29
|
+
head :not_found
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
@@ -0,0 +1,115 @@
|
|
1
|
+
module RailsPulse
|
2
|
+
class CachesController < ApplicationController
|
3
|
+
def show
|
4
|
+
@component_id = params[:id]
|
5
|
+
@context = params[:context]
|
6
|
+
@cache_key = ComponentCacheKey.build(@component_id, @context)
|
7
|
+
|
8
|
+
# Preserve component options before refresh
|
9
|
+
existing_options = {}
|
10
|
+
if params[:refresh]
|
11
|
+
existing_cache = Rails.cache.read(@cache_key)
|
12
|
+
existing_options = existing_cache[:component_options] if existing_cache&.dig(:component_options)
|
13
|
+
Rails.cache.delete(@cache_key)
|
14
|
+
end
|
15
|
+
|
16
|
+
# Check if cache exists with just options (from render_skeleton_with_frame)
|
17
|
+
cached_data = Rails.cache.read(@cache_key)
|
18
|
+
if cached_data && !cached_data[:component_data]
|
19
|
+
# Merge options with full data
|
20
|
+
cached_data = {
|
21
|
+
component_data: calculate_component_data,
|
22
|
+
cached_at: Time.current,
|
23
|
+
component_options: cached_data[:component_options] || {}
|
24
|
+
}
|
25
|
+
Rails.cache.write(@cache_key, cached_data, expires_in: ComponentCacheKey.cache_expires_in)
|
26
|
+
elsif !cached_data
|
27
|
+
# No cache exists, create new one (use preserved options if refreshing)
|
28
|
+
cached_data = {
|
29
|
+
component_data: calculate_component_data,
|
30
|
+
cached_at: Time.current,
|
31
|
+
component_options: existing_options
|
32
|
+
}
|
33
|
+
Rails.cache.write(@cache_key, cached_data, expires_in: ComponentCacheKey.cache_expires_in)
|
34
|
+
end
|
35
|
+
|
36
|
+
@component_data = cached_data[:component_data]
|
37
|
+
@cached_at = cached_data[:cached_at]
|
38
|
+
@component_options = cached_data[:component_options] || {}
|
39
|
+
|
40
|
+
# Update cached_at timestamp in component options if refresh action exists
|
41
|
+
if params[:refresh] && @component_options[:actions]
|
42
|
+
update_cached_at_in_actions(@component_options[:actions], @cached_at)
|
43
|
+
# Update the unified cache with new cached_at timestamp
|
44
|
+
cached_data[:component_options] = @component_options
|
45
|
+
Rails.cache.write(@cache_key, cached_data, expires_in: ComponentCacheKey.cache_expires_in)
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
private
|
50
|
+
|
51
|
+
def calculate_component_data
|
52
|
+
route = extract_route_from_context
|
53
|
+
query = extract_query_from_context
|
54
|
+
|
55
|
+
case @component_id
|
56
|
+
when "average_response_times"
|
57
|
+
Routes::Cards::AverageResponseTimes.new(route: route).to_metric_card
|
58
|
+
when "percentile_response_times"
|
59
|
+
Routes::Cards::PercentileResponseTimes.new(route: route).to_metric_card
|
60
|
+
when "request_count_totals"
|
61
|
+
Routes::Cards::RequestCountTotals.new(route: route).to_metric_card
|
62
|
+
when "error_rate_per_route"
|
63
|
+
Routes::Cards::ErrorRatePerRoute.new(route: route).to_metric_card
|
64
|
+
when "average_query_times"
|
65
|
+
Queries::Cards::AverageQueryTimes.new(query: query).to_metric_card
|
66
|
+
when "percentile_query_times"
|
67
|
+
Queries::Cards::PercentileQueryTimes.new(query: query).to_metric_card
|
68
|
+
when "execution_rate"
|
69
|
+
Queries::Cards::ExecutionRate.new(query: query).to_metric_card
|
70
|
+
when "dashboard_average_response_time"
|
71
|
+
Dashboard::Charts::AverageResponseTime.new.to_chart_data
|
72
|
+
when "dashboard_p95_response_time"
|
73
|
+
Dashboard::Charts::P95ResponseTime.new.to_chart_data
|
74
|
+
when "dashboard_slow_routes"
|
75
|
+
Dashboard::Tables::SlowRoutes.new.to_table_data
|
76
|
+
when "dashboard_slow_queries"
|
77
|
+
Dashboard::Tables::SlowQueries.new.to_table_data
|
78
|
+
else
|
79
|
+
{ title: "Unknown Metric", summary: "N/A" }
|
80
|
+
end
|
81
|
+
end
|
82
|
+
|
83
|
+
def extract_route_from_context
|
84
|
+
return unless @context
|
85
|
+
|
86
|
+
# Extract route ID from context like "route_123" or return nil for "routes"/"requests"
|
87
|
+
if @context.match(/^route_(\d+)$/)
|
88
|
+
route_id = @context.match(/^route_(\d+)$/)[1]
|
89
|
+
Route.find(route_id)
|
90
|
+
else
|
91
|
+
nil
|
92
|
+
end
|
93
|
+
end
|
94
|
+
|
95
|
+
def extract_query_from_context
|
96
|
+
return unless @context
|
97
|
+
|
98
|
+
# Extract query ID from context like "query_123" or return nil for other contexts
|
99
|
+
if @context.match(/^query_(\d+)$/)
|
100
|
+
query_id = @context.match(/^query_(\d+)$/)[1]
|
101
|
+
Query.find(query_id)
|
102
|
+
else
|
103
|
+
nil
|
104
|
+
end
|
105
|
+
end
|
106
|
+
|
107
|
+
def update_cached_at_in_actions(actions, cached_at)
|
108
|
+
actions.each do |action|
|
109
|
+
if action.dig(:data, :rails_pulse__timezone_cached_at_value)
|
110
|
+
action[:data][:rails_pulse__timezone_cached_at_value] = cached_at.iso8601
|
111
|
+
end
|
112
|
+
end
|
113
|
+
end
|
114
|
+
end
|
115
|
+
end
|
@@ -0,0 +1,57 @@
|
|
1
|
+
# CSP Test Controller for Rails Pulse
|
2
|
+
# Tests Content Security Policy compliance with strict policies
|
3
|
+
|
4
|
+
class RailsPulse::CspTestController < RailsPulse::ApplicationController
|
5
|
+
# Strict CSP configuration for testing
|
6
|
+
before_action :set_strict_csp
|
7
|
+
|
8
|
+
def show
|
9
|
+
respond_to do |format|
|
10
|
+
format.html { render :show }
|
11
|
+
format.json { render json: { status: "ok", message: "CSP test endpoint working" } }
|
12
|
+
end
|
13
|
+
end
|
14
|
+
|
15
|
+
private
|
16
|
+
|
17
|
+
def set_strict_csp
|
18
|
+
# Strict Content Security Policy for testing Rails Pulse CSP compliance
|
19
|
+
csp_directives = {
|
20
|
+
"default-src" => "'self'",
|
21
|
+
"script-src" => build_script_src,
|
22
|
+
"style-src" => build_style_src,
|
23
|
+
"style-src-attr" => "'unsafe-hashes' 'unsafe-inline'", # CSS custom properties
|
24
|
+
"img-src" => "'self' data:",
|
25
|
+
"font-src" => "'self'",
|
26
|
+
"connect-src" => "'self'",
|
27
|
+
"frame-src" => "'none'",
|
28
|
+
"object-src" => "'none'",
|
29
|
+
"base-uri" => "'self'",
|
30
|
+
"form-action" => "'self'"
|
31
|
+
}
|
32
|
+
|
33
|
+
response.headers["Content-Security-Policy"] = csp_directives.map { |k, v| "#{k} #{v}" }.join("; ")
|
34
|
+
end
|
35
|
+
|
36
|
+
def build_script_src
|
37
|
+
[
|
38
|
+
"'self'",
|
39
|
+
"'nonce-#{request_nonce}'",
|
40
|
+
"'sha256-ieoeWczDHkReVBsRBqaal5AFMlBtNjMzgwKvLqi/tSU='" # Known safe inline script
|
41
|
+
].join(" ")
|
42
|
+
end
|
43
|
+
|
44
|
+
def build_style_src
|
45
|
+
[
|
46
|
+
"'self'",
|
47
|
+
"'nonce-#{request_nonce}'",
|
48
|
+
"'sha256-WAyOw4V+FqDc35lQPyRADLBWbuNK8ahvYEaQIYF1+Ps='" # Icon controller styles
|
49
|
+
].join(" ")
|
50
|
+
end
|
51
|
+
|
52
|
+
def request_nonce
|
53
|
+
@request_nonce ||= SecureRandom.base64(32)
|
54
|
+
end
|
55
|
+
|
56
|
+
helper_method :request_nonce
|
57
|
+
end
|