rails_pulse 0.1.2 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +66 -20
- data/Rakefile +169 -86
- data/app/assets/images/rails_pulse/dashboard.png +0 -0
- data/app/assets/images/rails_pulse/request.png +0 -0
- data/app/assets/stylesheets/rails_pulse/application.css +28 -5
- data/app/assets/stylesheets/rails_pulse/components/badge.css +13 -0
- data/app/assets/stylesheets/rails_pulse/components/base.css +12 -2
- data/app/assets/stylesheets/rails_pulse/components/collapsible.css +30 -0
- data/app/assets/stylesheets/rails_pulse/components/popover.css +0 -1
- data/app/assets/stylesheets/rails_pulse/components/row.css +55 -3
- data/app/assets/stylesheets/rails_pulse/components/sidebar_menu.css +23 -0
- data/app/controllers/concerns/zoom_range_concern.rb +31 -0
- data/app/controllers/rails_pulse/application_controller.rb +5 -1
- data/app/controllers/rails_pulse/queries_controller.rb +49 -10
- data/app/controllers/rails_pulse/requests_controller.rb +46 -20
- data/app/controllers/rails_pulse/routes_controller.rb +40 -1
- data/app/helpers/rails_pulse/breadcrumbs_helper.rb +1 -1
- data/app/helpers/rails_pulse/chart_helper.rb +16 -8
- data/app/helpers/rails_pulse/formatting_helper.rb +21 -2
- data/app/javascript/rails_pulse/application.js +34 -3
- data/app/javascript/rails_pulse/controllers/collapsible_controller.js +32 -0
- data/app/javascript/rails_pulse/controllers/color_scheme_controller.js +2 -1
- data/app/javascript/rails_pulse/controllers/expandable_rows_controller.js +58 -0
- data/app/javascript/rails_pulse/controllers/index_controller.js +249 -11
- data/app/javascript/rails_pulse/controllers/popover_controller.js +28 -4
- data/app/javascript/rails_pulse/controllers/table_sort_controller.js +14 -0
- data/app/models/rails_pulse/dashboard/tables/slow_queries.rb +1 -1
- data/app/models/rails_pulse/dashboard/tables/slow_routes.rb +1 -1
- data/app/models/rails_pulse/queries/cards/average_query_times.rb +20 -20
- data/app/models/rails_pulse/queries/cards/execution_rate.rb +58 -14
- data/app/models/rails_pulse/queries/cards/percentile_query_times.rb +14 -9
- data/app/models/rails_pulse/queries/charts/average_query_times.rb +3 -7
- data/app/models/rails_pulse/query.rb +46 -0
- data/app/models/rails_pulse/request.rb +1 -1
- data/app/models/rails_pulse/requests/charts/average_response_times.rb +2 -2
- data/app/models/rails_pulse/requests/tables/index.rb +77 -0
- data/app/models/rails_pulse/routes/cards/average_response_times.rb +18 -20
- data/app/models/rails_pulse/routes/cards/error_rate_per_route.rb +14 -9
- data/app/models/rails_pulse/routes/cards/percentile_response_times.rb +14 -9
- data/app/models/rails_pulse/routes/cards/request_count_totals.rb +29 -13
- data/app/models/rails_pulse/routes/tables/index.rb +4 -2
- data/app/models/rails_pulse/summary.rb +7 -7
- data/app/services/rails_pulse/analysis/backtrace_analyzer.rb +256 -0
- data/app/services/rails_pulse/analysis/base_analyzer.rb +67 -0
- data/app/services/rails_pulse/analysis/explain_plan_analyzer.rb +206 -0
- data/app/services/rails_pulse/analysis/index_recommendation_engine.rb +326 -0
- data/app/services/rails_pulse/analysis/n_plus_one_detector.rb +241 -0
- data/app/services/rails_pulse/analysis/query_characteristics_analyzer.rb +154 -0
- data/app/services/rails_pulse/analysis/suggestion_generator.rb +217 -0
- data/app/services/rails_pulse/query_analysis_service.rb +125 -0
- data/app/views/layouts/rails_pulse/_sidebar_menu.html.erb +0 -1
- data/app/views/layouts/rails_pulse/application.html.erb +0 -2
- data/app/views/rails_pulse/components/_breadcrumbs.html.erb +1 -1
- data/app/views/rails_pulse/components/_code_panel.html.erb +17 -3
- data/app/views/rails_pulse/components/_empty_state.html.erb +1 -1
- data/app/views/rails_pulse/components/_metric_card.html.erb +28 -5
- data/app/views/rails_pulse/components/_operation_details_popover.html.erb +1 -1
- data/app/views/rails_pulse/components/_panel.html.erb +1 -1
- data/app/views/rails_pulse/components/_sparkline_stats.html.erb +5 -7
- data/app/views/rails_pulse/components/_table_head.html.erb +6 -1
- data/app/views/rails_pulse/dashboard/index.html.erb +2 -2
- data/app/views/rails_pulse/operations/show.html.erb +17 -15
- data/app/views/rails_pulse/queries/_analysis_error.html.erb +15 -0
- data/app/views/rails_pulse/queries/_analysis_prompt.html.erb +27 -0
- data/app/views/rails_pulse/queries/_analysis_results.html.erb +117 -0
- data/app/views/rails_pulse/queries/_analysis_section.html.erb +39 -0
- data/app/views/rails_pulse/queries/_show_table.html.erb +34 -6
- data/app/views/rails_pulse/queries/_table.html.erb +4 -8
- data/app/views/rails_pulse/queries/index.html.erb +48 -51
- data/app/views/rails_pulse/queries/show.html.erb +56 -52
- data/app/views/rails_pulse/requests/_operations.html.erb +30 -43
- data/app/views/rails_pulse/requests/_table.html.erb +31 -18
- data/app/views/rails_pulse/requests/index.html.erb +55 -50
- data/app/views/rails_pulse/requests/show.html.erb +0 -2
- data/app/views/rails_pulse/routes/_requests_table.html.erb +39 -0
- data/app/views/rails_pulse/routes/_table.html.erb +4 -10
- data/app/views/rails_pulse/routes/index.html.erb +49 -52
- data/app/views/rails_pulse/routes/show.html.erb +6 -8
- data/config/initializers/rails_charts_csp_patch.rb +32 -40
- data/config/routes.rb +5 -1
- data/db/migrate/20250930105043_install_rails_pulse_tables.rb +23 -0
- data/db/rails_pulse_schema.rb +10 -1
- data/lib/generators/rails_pulse/convert_to_migrations_generator.rb +81 -0
- data/lib/generators/rails_pulse/install_generator.rb +75 -18
- data/lib/generators/rails_pulse/templates/db/rails_pulse_schema.rb +72 -2
- data/lib/generators/rails_pulse/templates/migrations/install_rails_pulse_tables.rb +23 -0
- data/lib/generators/rails_pulse/templates/migrations/upgrade_rails_pulse_tables.rb +19 -0
- data/lib/generators/rails_pulse/upgrade_generator.rb +226 -0
- data/lib/rails_pulse/engine.rb +21 -0
- data/lib/rails_pulse/version.rb +1 -1
- data/lib/tasks/rails_pulse.rake +27 -8
- data/public/rails-pulse-assets/rails-pulse.css +1 -1
- data/public/rails-pulse-assets/rails-pulse.css.map +1 -1
- data/public/rails-pulse-assets/rails-pulse.js +53 -53
- data/public/rails-pulse-assets/rails-pulse.js.map +4 -4
- metadata +25 -6
- data/app/assets/images/rails_pulse/rails-pulse-logo.png +0 -0
- data/app/assets/images/rails_pulse/routes.png +0 -0
- data/app/javascript/rails_pulse/controllers/expandable_row_controller.js +0 -67
- data/db/migrate/20241222000001_create_rails_pulse_summaries.rb +0 -54
@@ -77,3 +77,26 @@
|
|
77
77
|
padding: var(--size-0_5) var(--size-2);
|
78
78
|
row-gap: var(--size-1);
|
79
79
|
}
|
80
|
+
|
81
|
+
/* Sheet component styles for mobile menu */
|
82
|
+
.sheet {
|
83
|
+
border: 0;
|
84
|
+
background: var(--color-bg);
|
85
|
+
max-block-size: none;
|
86
|
+
max-inline-size: none;
|
87
|
+
padding: 0;
|
88
|
+
}
|
89
|
+
|
90
|
+
.sheet--left {
|
91
|
+
block-size: 100vh;
|
92
|
+
inline-size: var(--sheet-size, 288px);
|
93
|
+
inset-block-start: 0;
|
94
|
+
inset-inline-start: 0;
|
95
|
+
}
|
96
|
+
|
97
|
+
.sheet__content {
|
98
|
+
block-size: 100%;
|
99
|
+
display: flex;
|
100
|
+
flex-direction: column;
|
101
|
+
overflow-y: auto;
|
102
|
+
}
|
@@ -2,10 +2,22 @@ module ZoomRangeConcern
|
|
2
2
|
extend ActiveSupport::Concern
|
3
3
|
|
4
4
|
def setup_zoom_range(main_start_time, main_end_time)
|
5
|
+
# Extract column selection parameter (but don't delete it yet - we need it for the view)
|
6
|
+
selected_column_time = params[:selected_column_time]
|
7
|
+
|
5
8
|
# Extract zoom parameters from params (this removes them from params)
|
6
9
|
zoom_start = params.delete(:zoom_start_time)
|
7
10
|
zoom_end = params.delete(:zoom_end_time)
|
8
11
|
|
12
|
+
# Handle column selection with highest precedence for table filtering
|
13
|
+
if selected_column_time
|
14
|
+
column_start, column_end = normalize_column_time(selected_column_time.to_i, main_start_time, main_end_time)
|
15
|
+
table_start_time = column_start
|
16
|
+
table_end_time = column_end
|
17
|
+
# Don't set zoom times for column selection - let chart show full range
|
18
|
+
return [ zoom_start, zoom_end, table_start_time, table_end_time ]
|
19
|
+
end
|
20
|
+
|
9
21
|
# Normalize zoom times to beginning/end of day or hour like we do for main time range
|
10
22
|
if zoom_start && zoom_end
|
11
23
|
zoom_start, zoom_end = normalize_zoom_times(zoom_start.to_i, zoom_end.to_i)
|
@@ -20,6 +32,25 @@ module ZoomRangeConcern
|
|
20
32
|
|
21
33
|
private
|
22
34
|
|
35
|
+
def normalize_column_time(column_time, main_start_time, main_end_time)
|
36
|
+
# Determine period type based on main time range (same logic as ChartTableConcern)
|
37
|
+
time_diff_hours = (main_end_time - main_start_time) / 3600.0
|
38
|
+
|
39
|
+
if time_diff_hours <= 25
|
40
|
+
# Hourly period - normalize to beginning/end of hour
|
41
|
+
column_time_obj = Time.zone&.at(column_time) || Time.at(column_time)
|
42
|
+
start_time = column_time_obj&.beginning_of_hour || column_time_obj
|
43
|
+
end_time = column_time_obj&.end_of_hour || column_time_obj
|
44
|
+
else
|
45
|
+
# Daily period - normalize to beginning/end of day
|
46
|
+
column_time_obj = Time.zone&.at(column_time) || Time.at(column_time)
|
47
|
+
start_time = column_time_obj&.beginning_of_day || column_time_obj
|
48
|
+
end_time = column_time_obj&.end_of_day || column_time_obj
|
49
|
+
end
|
50
|
+
|
51
|
+
[ start_time.to_i, end_time.to_i ]
|
52
|
+
end
|
53
|
+
|
23
54
|
def normalize_zoom_times(start_time, end_time)
|
24
55
|
time_diff = (end_time - start_time) / 3600.0
|
25
56
|
|
@@ -5,7 +5,11 @@ module RailsPulse
|
|
5
5
|
def set_pagination_limit(limit = nil)
|
6
6
|
limit = limit || params[:limit]
|
7
7
|
session[:pagination_limit] = limit.to_i if limit.present?
|
8
|
-
|
8
|
+
|
9
|
+
# Render JSON for direct API calls or AJAX requests (but not turbo frame requests)
|
10
|
+
if (request.xhr? && !turbo_frame_request?) || (request.patch? && action_name == "set_pagination_limit")
|
11
|
+
render json: { status: "ok" }
|
12
|
+
end
|
9
13
|
end
|
10
14
|
|
11
15
|
private
|
@@ -2,7 +2,7 @@ module RailsPulse
|
|
2
2
|
class QueriesController < ApplicationController
|
3
3
|
include ChartTableConcern
|
4
4
|
|
5
|
-
before_action :set_query, only: :show
|
5
|
+
before_action :set_query, only: [ :show, :analyze ]
|
6
6
|
|
7
7
|
def index
|
8
8
|
setup_metric_cards
|
@@ -14,6 +14,40 @@ module RailsPulse
|
|
14
14
|
setup_chart_and_table_data
|
15
15
|
end
|
16
16
|
|
17
|
+
def analyze
|
18
|
+
begin
|
19
|
+
@analysis_results = QueryAnalysisService.analyze_query(@query.id)
|
20
|
+
|
21
|
+
respond_to do |format|
|
22
|
+
format.turbo_stream {
|
23
|
+
render turbo_stream: turbo_stream.replace(
|
24
|
+
"query_analysis",
|
25
|
+
partial: "rails_pulse/queries/analysis_section",
|
26
|
+
locals: { query: @query.reload }
|
27
|
+
)
|
28
|
+
}
|
29
|
+
format.html {
|
30
|
+
redirect_to query_path(@query), notice: "Query analysis completed successfully."
|
31
|
+
}
|
32
|
+
end
|
33
|
+
rescue => e
|
34
|
+
Rails.logger.error("[QueryAnalysis] Analysis failed for query #{@query.id}: #{e.message}")
|
35
|
+
|
36
|
+
respond_to do |format|
|
37
|
+
format.turbo_stream {
|
38
|
+
render turbo_stream: turbo_stream.replace(
|
39
|
+
"query_analysis",
|
40
|
+
partial: "rails_pulse/queries/analysis_section",
|
41
|
+
locals: { query: @query, error_message: "Analysis failed: #{e.message}" }
|
42
|
+
)
|
43
|
+
}
|
44
|
+
format.html {
|
45
|
+
redirect_to query_path(@query), alert: "Query analysis failed: #{e.message}"
|
46
|
+
}
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
17
51
|
private
|
18
52
|
|
19
53
|
def chart_model
|
@@ -21,7 +55,7 @@ module RailsPulse
|
|
21
55
|
end
|
22
56
|
|
23
57
|
def table_model
|
24
|
-
|
58
|
+
Summary
|
25
59
|
end
|
26
60
|
|
27
61
|
def chart_class
|
@@ -42,7 +76,10 @@ module RailsPulse
|
|
42
76
|
base_params[:avg_duration_gteq] = @start_duration if @start_duration && @start_duration > 0
|
43
77
|
|
44
78
|
if show_action?
|
45
|
-
base_params.merge(
|
79
|
+
base_params.merge(
|
80
|
+
summarizable_id_eq: @query.id,
|
81
|
+
summarizable_type_eq: "RailsPulse::Query"
|
82
|
+
)
|
46
83
|
else
|
47
84
|
base_params
|
48
85
|
end
|
@@ -50,13 +87,14 @@ module RailsPulse
|
|
50
87
|
|
51
88
|
def build_table_ransack_params(ransack_params)
|
52
89
|
if show_action?
|
53
|
-
# For
|
90
|
+
# For Summary model on show page
|
54
91
|
params = ransack_params.merge(
|
55
|
-
|
56
|
-
|
57
|
-
|
92
|
+
period_start_gteq: Time.at(@table_start_time),
|
93
|
+
period_start_lt: Time.at(@table_end_time),
|
94
|
+
summarizable_id_eq: @query.id,
|
95
|
+
summarizable_type_eq: "RailsPulse::Query"
|
58
96
|
)
|
59
|
-
params[:
|
97
|
+
params[:avg_duration_gteq] = @start_duration if @start_duration && @start_duration > 0
|
60
98
|
params
|
61
99
|
else
|
62
100
|
# For Summary model on index page
|
@@ -70,12 +108,13 @@ module RailsPulse
|
|
70
108
|
end
|
71
109
|
|
72
110
|
def default_table_sort
|
73
|
-
|
111
|
+
"period_start desc"
|
74
112
|
end
|
75
113
|
|
76
114
|
def build_table_results
|
77
115
|
if show_action?
|
78
|
-
|
116
|
+
# For Summary model on show page - ransack params already include query ID and type filters
|
117
|
+
@ransack_query.result.where(period_type: period_type)
|
79
118
|
else
|
80
119
|
Queries::Tables::Index.new(
|
81
120
|
ransack_query: @ransack_query,
|
@@ -5,13 +5,7 @@ module RailsPulse
|
|
5
5
|
before_action :set_request, only: :show
|
6
6
|
|
7
7
|
def index
|
8
|
-
|
9
|
-
@average_response_times_metric_card = RailsPulse::Routes::Cards::AverageResponseTimes.new(route: nil).to_metric_card
|
10
|
-
@percentile_response_times_metric_card = RailsPulse::Routes::Cards::PercentileResponseTimes.new(route: nil).to_metric_card
|
11
|
-
@request_count_totals_metric_card = RailsPulse::Routes::Cards::RequestCountTotals.new(route: nil).to_metric_card
|
12
|
-
@error_rate_per_route_metric_card = RailsPulse::Routes::Cards::ErrorRatePerRoute.new(route: nil).to_metric_card
|
13
|
-
end
|
14
|
-
|
8
|
+
setup_metric_cards
|
15
9
|
setup_chart_and_table_data
|
16
10
|
end
|
17
11
|
|
@@ -21,12 +15,22 @@ module RailsPulse
|
|
21
15
|
|
22
16
|
private
|
23
17
|
|
18
|
+
def setup_metric_cards
|
19
|
+
return if turbo_frame_request?
|
20
|
+
|
21
|
+
@average_response_times_metric_card = RailsPulse::Routes::Cards::AverageResponseTimes.new(route: nil).to_metric_card
|
22
|
+
@percentile_response_times_metric_card = RailsPulse::Routes::Cards::PercentileResponseTimes.new(route: nil).to_metric_card
|
23
|
+
@request_count_totals_metric_card = RailsPulse::Routes::Cards::RequestCountTotals.new(route: nil).to_metric_card
|
24
|
+
@error_rate_per_route_metric_card = RailsPulse::Routes::Cards::ErrorRatePerRoute.new(route: nil).to_metric_card
|
25
|
+
end
|
26
|
+
|
27
|
+
|
24
28
|
def chart_model
|
25
|
-
Summary
|
29
|
+
RailsPulse::Summary
|
26
30
|
end
|
27
31
|
|
28
32
|
def table_model
|
29
|
-
Request
|
33
|
+
RailsPulse::Request
|
30
34
|
end
|
31
35
|
|
32
36
|
def chart_class
|
@@ -40,7 +44,9 @@ module RailsPulse
|
|
40
44
|
def build_chart_ransack_params(ransack_params)
|
41
45
|
base_params = ransack_params.except(:s).merge(
|
42
46
|
period_start_gteq: Time.at(@start_time),
|
43
|
-
period_start_lt: Time.at(@end_time)
|
47
|
+
period_start_lt: Time.at(@end_time),
|
48
|
+
summarizable_type_eq: "RailsPulse::Request",
|
49
|
+
summarizable_id_eq: 0
|
44
50
|
)
|
45
51
|
|
46
52
|
# Only add duration filter if we have a meaningful threshold
|
@@ -62,16 +68,36 @@ module RailsPulse
|
|
62
68
|
end
|
63
69
|
|
64
70
|
def build_table_results
|
65
|
-
@ransack_query.result
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
|
71
|
+
base_query = @ransack_query.result.includes(:route)
|
72
|
+
|
73
|
+
# If sorting by route_path, we need to join the routes table
|
74
|
+
if @ransack_query.sorts.any? { |sort| sort.name == "route_path" }
|
75
|
+
base_query = base_query.joins(:route)
|
76
|
+
end
|
77
|
+
|
78
|
+
base_query
|
79
|
+
end
|
80
|
+
|
81
|
+
|
82
|
+
def setup_table_data(ransack_params)
|
83
|
+
table_ransack_params = build_table_ransack_params(ransack_params)
|
84
|
+
@ransack_query = table_model.ransack(table_ransack_params)
|
85
|
+
|
86
|
+
# Only apply default sort if not using Requests::Tables::Index (which handles its own sorting)
|
87
|
+
# For requests, we always use the Tables::Index on the index action
|
88
|
+
unless action_name == "index"
|
89
|
+
@ransack_query.sorts = default_table_sort if @ransack_query.sorts.empty?
|
90
|
+
end
|
91
|
+
|
92
|
+
table_results = build_table_results
|
93
|
+
handle_pagination
|
94
|
+
|
95
|
+
@pagy, @table_data = pagy(table_results, limit: session_pagination_limit)
|
96
|
+
end
|
97
|
+
|
98
|
+
def handle_pagination
|
99
|
+
method = pagination_method
|
100
|
+
send(method, params[:limit]) if params[:limit].present?
|
75
101
|
end
|
76
102
|
|
77
103
|
def set_request
|
@@ -84,7 +84,25 @@ module RailsPulse
|
|
84
84
|
|
85
85
|
def build_table_results
|
86
86
|
if show_action?
|
87
|
-
|
87
|
+
# Only show requests that belong to time periods where we have route summaries
|
88
|
+
# This ensures the table data is consistent with the chart data
|
89
|
+
base_query = @ransack_query.result
|
90
|
+
.joins(<<~SQL)
|
91
|
+
INNER JOIN rails_pulse_summaries ON
|
92
|
+
rails_pulse_summaries.summarizable_id = rails_pulse_requests.route_id AND
|
93
|
+
rails_pulse_summaries.summarizable_type = 'RailsPulse::Route' AND
|
94
|
+
rails_pulse_summaries.period_type = '#{period_type}' AND
|
95
|
+
rails_pulse_requests.occurred_at >= rails_pulse_summaries.period_start AND
|
96
|
+
rails_pulse_requests.occurred_at < rails_pulse_summaries.period_end
|
97
|
+
SQL
|
98
|
+
|
99
|
+
# For PostgreSQL compatibility with DISTINCT + ORDER BY
|
100
|
+
# we need to include computed columns in SELECT when ordering by them
|
101
|
+
if ordering_by_computed_column?
|
102
|
+
base_query.select("rails_pulse_requests.*, #{status_indicator_sql} as status_indicator_value").distinct
|
103
|
+
else
|
104
|
+
base_query.distinct
|
105
|
+
end
|
88
106
|
else
|
89
107
|
Routes::Tables::Index.new(
|
90
108
|
ransack_query: @ransack_query,
|
@@ -130,5 +148,26 @@ module RailsPulse
|
|
130
148
|
def set_route
|
131
149
|
@route = Route.find(params[:id])
|
132
150
|
end
|
151
|
+
|
152
|
+
def ordering_by_computed_column?
|
153
|
+
# Check if we're ordering by status_indicator (computed column)
|
154
|
+
@ransack_query.sorts.any? { |sort| sort.name == "status_indicator" }
|
155
|
+
end
|
156
|
+
|
157
|
+
def status_indicator_sql
|
158
|
+
# Same logic as in the Request model's ransacker
|
159
|
+
config = RailsPulse.configuration rescue nil
|
160
|
+
thresholds = config&.request_thresholds || { slow: 500, very_slow: 1000, critical: 2000 }
|
161
|
+
slow = thresholds[:slow] || 500
|
162
|
+
very_slow = thresholds[:very_slow] || 1000
|
163
|
+
critical = thresholds[:critical] || 2000
|
164
|
+
|
165
|
+
"CASE
|
166
|
+
WHEN rails_pulse_requests.duration < #{slow} THEN 0
|
167
|
+
WHEN rails_pulse_requests.duration < #{very_slow} THEN 1
|
168
|
+
WHEN rails_pulse_requests.duration < #{critical} THEN 2
|
169
|
+
ELSE 3
|
170
|
+
END"
|
171
|
+
end
|
133
172
|
end
|
134
173
|
end
|
@@ -27,7 +27,7 @@ module RailsPulse
|
|
27
27
|
top: "10%",
|
28
28
|
containLabel: true
|
29
29
|
},
|
30
|
-
animation:
|
30
|
+
animation: true
|
31
31
|
}
|
32
32
|
end
|
33
33
|
|
@@ -63,16 +63,24 @@ module RailsPulse
|
|
63
63
|
end
|
64
64
|
|
65
65
|
def sparkline_chart_options
|
66
|
+
# Compact sparkline columns that fill the canvas with no axes/labels/gaps
|
66
67
|
base_chart_options.deep_merge({
|
67
68
|
series: {
|
68
|
-
type: "
|
69
|
-
|
70
|
-
|
71
|
-
|
69
|
+
type: "bar",
|
70
|
+
itemStyle: { borderRadius: [ 2, 2, 0, 0 ] },
|
71
|
+
barCategoryGap: "10%",
|
72
|
+
barGap: "0%"
|
73
|
+
},
|
74
|
+
yAxis: { show: false, splitLine: { show: false } },
|
75
|
+
xAxis: {
|
76
|
+
type: "category",
|
77
|
+
boundaryGap: true,
|
78
|
+
axisLine: { show: false },
|
79
|
+
axisTick: { show: false },
|
80
|
+
splitLine: { show: false },
|
81
|
+
axisLabel: { show: false }
|
72
82
|
},
|
73
|
-
|
74
|
-
xAxis: { splitLine: { show: false } },
|
75
|
-
grid: { show: false }
|
83
|
+
grid: { left: 0, right: 0, top: 0, bottom: 0, containLabel: false, show: false }
|
76
84
|
})
|
77
85
|
end
|
78
86
|
|
@@ -3,7 +3,8 @@ module RailsPulse
|
|
3
3
|
def human_readable_occurred_at(occurred_at)
|
4
4
|
return "" unless occurred_at.present?
|
5
5
|
time = occurred_at.is_a?(String) ? Time.parse(occurred_at) : occurred_at
|
6
|
-
|
6
|
+
# Convert to local system timezone (same as charts use)
|
7
|
+
time.getlocal.strftime("%b %d, %Y %l:%M %p")
|
7
8
|
end
|
8
9
|
|
9
10
|
def time_ago_in_words(time)
|
@@ -11,8 +12,10 @@ module RailsPulse
|
|
11
12
|
|
12
13
|
# Convert to Time object if it's a string
|
13
14
|
time = Time.parse(time.to_s) if time.is_a?(String)
|
15
|
+
# Convert to local system timezone for consistent calculation
|
16
|
+
time = time.getlocal
|
14
17
|
|
15
|
-
seconds_ago = Time.
|
18
|
+
seconds_ago = Time.now - time
|
16
19
|
|
17
20
|
case seconds_ago
|
18
21
|
when 0..59
|
@@ -25,5 +28,21 @@ module RailsPulse
|
|
25
28
|
"#{(seconds_ago / 86400).to_i}d ago"
|
26
29
|
end
|
27
30
|
end
|
31
|
+
|
32
|
+
def human_readable_summary_period(summary)
|
33
|
+
return "" unless summary&.period_start&.present? && summary&.period_end&.present?
|
34
|
+
|
35
|
+
# Convert UTC times to local system timezone to match chart display
|
36
|
+
start_time = summary.period_start.getlocal
|
37
|
+
end_time = summary.period_end.getlocal
|
38
|
+
|
39
|
+
|
40
|
+
case summary.period_type
|
41
|
+
when "hour"
|
42
|
+
start_time.strftime("%b %e %Y, %l:%M %p") + " - " + end_time.strftime("%l:%M %p")
|
43
|
+
when "day"
|
44
|
+
start_time.strftime("%b %e, %Y")
|
45
|
+
end
|
46
|
+
end
|
28
47
|
end
|
29
48
|
end
|
@@ -16,7 +16,9 @@ import ColorSchemeController from "./controllers/color_scheme_controller";
|
|
16
16
|
import PaginationController from "./controllers/pagination_controller";
|
17
17
|
import TimezoneController from "./controllers/timezone_controller";
|
18
18
|
import IconController from "./controllers/icon_controller";
|
19
|
-
import
|
19
|
+
import ExpandableRowsController from "./controllers/expandable_rows_controller";
|
20
|
+
import CollapsibleController from "./controllers/collapsible_controller";
|
21
|
+
import TableSortController from "./controllers/table_sort_controller";
|
20
22
|
|
21
23
|
const application = Application.start();
|
22
24
|
|
@@ -41,7 +43,9 @@ application.register("rails-pulse--color-scheme", ColorSchemeController);
|
|
41
43
|
application.register("rails-pulse--pagination", PaginationController);
|
42
44
|
application.register("rails-pulse--timezone", TimezoneController);
|
43
45
|
application.register("rails-pulse--icon", IconController);
|
44
|
-
application.register("rails-pulse--expandable-
|
46
|
+
application.register("rails-pulse--expandable-rows", ExpandableRowsController);
|
47
|
+
application.register("rails-pulse--collapsible", CollapsibleController);
|
48
|
+
application.register("rails-pulse--table-sort", TableSortController);
|
45
49
|
|
46
50
|
// Ensure Turbo Frames are loaded after page load
|
47
51
|
document.addEventListener('DOMContentLoaded', () => {
|
@@ -95,6 +99,32 @@ window.addEventListener('resize', function() {
|
|
95
99
|
}
|
96
100
|
});
|
97
101
|
|
102
|
+
// Apply axis label colors based on current color scheme
|
103
|
+
function applyChartAxisLabelColors() {
|
104
|
+
if (!window.RailsCharts || !window.RailsCharts.charts) return;
|
105
|
+
const scheme = document.documentElement.getAttribute('data-color-scheme');
|
106
|
+
const isDark = scheme === 'dark';
|
107
|
+
const axisColor = isDark ? '#ffffff' : '#999999';
|
108
|
+
Object.keys(window.RailsCharts.charts).forEach(function(chartID) {
|
109
|
+
const chart = window.RailsCharts.charts[chartID];
|
110
|
+
try {
|
111
|
+
chart.setOption({
|
112
|
+
xAxis: { axisLabel: { color: axisColor } },
|
113
|
+
yAxis: { axisLabel: { color: axisColor } }
|
114
|
+
});
|
115
|
+
} catch (e) {
|
116
|
+
// noop
|
117
|
+
}
|
118
|
+
});
|
119
|
+
}
|
120
|
+
|
121
|
+
// Initial apply after charts initialize and on scheme changes
|
122
|
+
document.addEventListener('DOMContentLoaded', () => {
|
123
|
+
// run shortly after load to allow charts to initialize
|
124
|
+
setTimeout(applyChartAxisLabelColors, 50);
|
125
|
+
});
|
126
|
+
document.addEventListener('rails-pulse:color-scheme-changed', applyChartAxisLabelColors);
|
127
|
+
|
98
128
|
// Global function to initialize Rails Charts in any container.
|
99
129
|
// This is needed as we render Rails Charts in Turbo Frames.
|
100
130
|
window.initializeChartsInContainer = function(containerId) {
|
@@ -108,6 +138,8 @@ window.initializeChartsInContainer = function(containerId) {
|
|
108
138
|
window[match[1]]();
|
109
139
|
}
|
110
140
|
});
|
141
|
+
// ensure colors are correct for any charts initialized in this container
|
142
|
+
setTimeout(applyChartAxisLabelColors, 10);
|
111
143
|
});
|
112
144
|
};
|
113
145
|
|
@@ -116,4 +148,3 @@ window.RailsPulse = {
|
|
116
148
|
application,
|
117
149
|
version: "1.0.0"
|
118
150
|
};
|
119
|
-
|
@@ -0,0 +1,32 @@
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
2
|
+
|
3
|
+
export default class extends Controller {
|
4
|
+
static targets = ["content", "toggle"]
|
5
|
+
static classes = ["collapsed"]
|
6
|
+
|
7
|
+
connect() {
|
8
|
+
this.collapse()
|
9
|
+
}
|
10
|
+
|
11
|
+
toggle() {
|
12
|
+
if (this.element.classList.contains(this.collapsedClass)) {
|
13
|
+
this.expand()
|
14
|
+
} else {
|
15
|
+
this.collapse()
|
16
|
+
}
|
17
|
+
}
|
18
|
+
|
19
|
+
collapse() {
|
20
|
+
this.element.classList.add(this.collapsedClass)
|
21
|
+
if (this.hasToggleTarget) {
|
22
|
+
this.toggleTarget.textContent = "show more"
|
23
|
+
}
|
24
|
+
}
|
25
|
+
|
26
|
+
expand() {
|
27
|
+
this.element.classList.remove(this.collapsedClass)
|
28
|
+
if (this.hasToggleTarget) {
|
29
|
+
this.toggleTarget.textContent = "show less"
|
30
|
+
}
|
31
|
+
}
|
32
|
+
}
|
@@ -13,8 +13,9 @@ export default class extends Controller {
|
|
13
13
|
toggle(event) {
|
14
14
|
event.preventDefault()
|
15
15
|
const current = this.html.getAttribute("data-color-scheme") === "dark" ? "light" : "dark"
|
16
|
-
console.log("Toggling color scheme to", current)
|
17
16
|
this.html.setAttribute("data-color-scheme", current)
|
18
17
|
localStorage.setItem(this.storageKey, current)
|
18
|
+
// Notify listeners (e.g., charts) that scheme changed
|
19
|
+
document.dispatchEvent(new CustomEvent('rails-pulse:color-scheme-changed', { detail: { scheme: current }}))
|
19
20
|
}
|
20
21
|
}
|
@@ -0,0 +1,58 @@
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
2
|
+
|
3
|
+
export default class extends Controller {
|
4
|
+
toggle(event) {
|
5
|
+
// Delegate clicks from tbody to the nearest row
|
6
|
+
const triggerRow = event.target.closest('tr')
|
7
|
+
if (!triggerRow || triggerRow.closest('tbody') !== this.element) return
|
8
|
+
|
9
|
+
// Ignore clicks on the details row itself
|
10
|
+
if (triggerRow.classList.contains('operation-details-row')) return
|
11
|
+
|
12
|
+
// Do not toggle when clicking the final Actions column
|
13
|
+
const clickedCell = event.target.closest('td,th')
|
14
|
+
if (clickedCell && clickedCell.parentElement === triggerRow) {
|
15
|
+
const isLastCell = clickedCell.cellIndex === (triggerRow.cells.length - 1)
|
16
|
+
if (isLastCell) return
|
17
|
+
}
|
18
|
+
|
19
|
+
event.preventDefault()
|
20
|
+
event.stopPropagation()
|
21
|
+
|
22
|
+
const detailsRow = triggerRow.nextElementSibling
|
23
|
+
if (!detailsRow || detailsRow.tagName !== 'TR' || !detailsRow.classList.contains('operation-details-row')) return
|
24
|
+
|
25
|
+
const isHidden = detailsRow.classList.contains('hidden')
|
26
|
+
if (isHidden) {
|
27
|
+
this.expand(triggerRow, detailsRow)
|
28
|
+
} else {
|
29
|
+
this.collapse(triggerRow, detailsRow)
|
30
|
+
}
|
31
|
+
}
|
32
|
+
|
33
|
+
expand(triggerRow, detailsRow) {
|
34
|
+
detailsRow.classList.remove('hidden')
|
35
|
+
|
36
|
+
// Rotate chevron to point down
|
37
|
+
const chevron = triggerRow.querySelector('.chevron')
|
38
|
+
if (chevron) chevron.style.transform = 'rotate(90deg)'
|
39
|
+
|
40
|
+
triggerRow.classList.add('expanded')
|
41
|
+
|
42
|
+
// Lazy load operation details once
|
43
|
+
const frame = detailsRow.querySelector('turbo-frame')
|
44
|
+
if (frame && !frame.getAttribute('src')) {
|
45
|
+
const url = frame.dataset.operationUrl
|
46
|
+
if (url) frame.setAttribute('src', url)
|
47
|
+
}
|
48
|
+
}
|
49
|
+
|
50
|
+
collapse(triggerRow, detailsRow) {
|
51
|
+
detailsRow.classList.add('hidden')
|
52
|
+
|
53
|
+
const chevron = triggerRow.querySelector('.chevron')
|
54
|
+
if (chevron) chevron.style.transform = 'rotate(0deg)'
|
55
|
+
|
56
|
+
triggerRow.classList.remove('expanded')
|
57
|
+
}
|
58
|
+
}
|