rails_pulse 0.1.4 → 0.2.2
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 +78 -0
- data/Rakefile +152 -3
- data/app/assets/images/rails_pulse/rails-pulse-logo.png +0 -0
- data/app/assets/stylesheets/rails_pulse/components/datepicker.css +191 -0
- data/app/assets/stylesheets/rails_pulse/components/switch.css +36 -0
- data/app/assets/stylesheets/rails_pulse/components/tags.css +98 -0
- data/app/assets/stylesheets/rails_pulse/components/utilities.css +26 -0
- data/app/controllers/concerns/response_range_concern.rb +15 -2
- data/app/controllers/concerns/tag_filter_concern.rb +26 -0
- data/app/controllers/concerns/time_range_concern.rb +27 -8
- data/app/controllers/rails_pulse/application_controller.rb +73 -0
- data/app/controllers/rails_pulse/queries_controller.rb +4 -1
- data/app/controllers/rails_pulse/requests_controller.rb +40 -8
- data/app/controllers/rails_pulse/routes_controller.rb +4 -2
- data/app/controllers/rails_pulse/tags_controller.rb +51 -0
- data/app/helpers/rails_pulse/application_helper.rb +2 -0
- data/app/helpers/rails_pulse/form_helper.rb +75 -0
- data/app/helpers/rails_pulse/tags_helper.rb +29 -0
- data/app/javascript/rails_pulse/application.js +6 -0
- data/app/javascript/rails_pulse/controllers/custom_range_controller.js +115 -0
- data/app/javascript/rails_pulse/controllers/datepicker_controller.js +48 -0
- data/app/javascript/rails_pulse/controllers/global_filters_controller.js +110 -0
- data/app/models/concerns/taggable.rb +61 -0
- data/app/models/rails_pulse/queries/tables/index.rb +10 -2
- data/app/models/rails_pulse/query.rb +2 -0
- data/app/models/rails_pulse/request.rb +9 -1
- data/app/models/rails_pulse/route.rb +2 -0
- data/app/models/rails_pulse/routes/tables/index.rb +10 -2
- data/app/services/rails_pulse/summary_service.rb +2 -0
- data/app/views/layouts/rails_pulse/_global_filters.html.erb +84 -0
- data/app/views/layouts/rails_pulse/_menu_items.html.erb +5 -5
- data/app/views/layouts/rails_pulse/application.html.erb +8 -5
- data/app/views/rails_pulse/components/_page_header.html.erb +20 -0
- data/app/views/rails_pulse/operations/show.html.erb +1 -1
- data/app/views/rails_pulse/queries/_table.html.erb +3 -1
- data/app/views/rails_pulse/queries/index.html.erb +3 -7
- data/app/views/rails_pulse/queries/show.html.erb +3 -7
- data/app/views/rails_pulse/requests/_table.html.erb +3 -1
- data/app/views/rails_pulse/requests/index.html.erb +44 -62
- data/app/views/rails_pulse/requests/show.html.erb +1 -1
- data/app/views/rails_pulse/routes/_requests_table.html.erb +3 -1
- data/app/views/rails_pulse/routes/_table.html.erb +3 -1
- data/app/views/rails_pulse/routes/index.html.erb +4 -8
- data/app/views/rails_pulse/routes/show.html.erb +3 -7
- data/app/views/rails_pulse/tags/_tag_manager.html.erb +73 -0
- data/config/routes.rb +5 -0
- data/db/rails_pulse_schema.rb +3 -0
- data/lib/generators/rails_pulse/install_generator.rb +21 -2
- data/lib/generators/rails_pulse/templates/db/rails_pulse_schema.rb +3 -0
- data/lib/generators/rails_pulse/templates/rails_pulse.rb +21 -0
- data/lib/generators/rails_pulse/upgrade_generator.rb +145 -29
- data/lib/rails_pulse/configuration.rb +16 -1
- data/lib/rails_pulse/version.rb +1 -1
- data/public/rails-pulse-assets/rails-pulse-icons.js +16 -15
- data/public/rails-pulse-assets/rails-pulse-icons.js.map +1 -1
- 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 +73 -69
- data/public/rails-pulse-assets/rails-pulse.js.map +4 -4
- metadata +17 -3
- data/app/views/rails_pulse/components/_breadcrumbs.html.erb +0 -12
@@ -0,0 +1,26 @@
|
|
1
|
+
module TagFilterConcern
|
2
|
+
extend ActiveSupport::Concern
|
3
|
+
|
4
|
+
private
|
5
|
+
|
6
|
+
# Apply tag filters to a query
|
7
|
+
# Excludes records that have ANY of the disabled tags
|
8
|
+
def apply_tag_filters(query)
|
9
|
+
disabled_tags = session_disabled_tags
|
10
|
+
query = disabled_tags.reduce(query) do |q, tag|
|
11
|
+
q.without_tag(tag)
|
12
|
+
end
|
13
|
+
|
14
|
+
apply_non_tagged_filter(query)
|
15
|
+
end
|
16
|
+
|
17
|
+
# Apply non-tagged filter to a query
|
18
|
+
# If show_non_tagged is false, exclude records with no tags
|
19
|
+
def apply_non_tagged_filter(query)
|
20
|
+
if session[:show_non_tagged] == false
|
21
|
+
query.with_tags
|
22
|
+
else
|
23
|
+
query
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
@@ -6,7 +6,8 @@ module TimeRangeConcern
|
|
6
6
|
const_set(:TIME_RANGE_OPTIONS, [
|
7
7
|
[ "Last 24 hours", :last_day ],
|
8
8
|
[ "Last Week", :last_week ],
|
9
|
-
[ "Last Month", :last_month ]
|
9
|
+
[ "Last Month", :last_month ],
|
10
|
+
[ "Custom Range...", :custom ]
|
10
11
|
].freeze)
|
11
12
|
end
|
12
13
|
|
@@ -17,11 +18,8 @@ module TimeRangeConcern
|
|
17
18
|
|
18
19
|
ransack_params = params[:q] || {}
|
19
20
|
|
20
|
-
|
21
|
-
|
22
|
-
start_time = parse_time_param(ransack_params[:occurred_at_gteq])
|
23
|
-
end_time = parse_time_param(ransack_params[:occurred_at_lt])
|
24
|
-
elsif ransack_params[:period_start_range]
|
21
|
+
# Priority 1: Page-specific preset from dropdown (check this first!)
|
22
|
+
if ransack_params[:period_start_range].present? && ransack_params[:period_start_range].to_sym != :custom
|
25
23
|
# Predefined time range from dropdown
|
26
24
|
selected_time_range = ransack_params[:period_start_range]
|
27
25
|
start_time =
|
@@ -31,7 +29,26 @@ module TimeRangeConcern
|
|
31
29
|
when :last_month then 1.month.ago
|
32
30
|
else 1.day.ago # Default fallback
|
33
31
|
end
|
32
|
+
# Priority 2: Page-specific custom datetime range from picker (only if period_start_range is :custom)
|
33
|
+
elsif ransack_params[:period_start_range].present? && ransack_params[:period_start_range].to_sym == :custom && ransack_params[:custom_date_range].present? && ransack_params[:custom_date_range].include?(" to ")
|
34
|
+
# Custom datetime range from custom range picker
|
35
|
+
dates = ransack_params[:custom_date_range].split(" to ")
|
36
|
+
start_time = parse_time_param(dates[0].strip)
|
37
|
+
end_time = parse_time_param(dates[1].strip)
|
38
|
+
selected_time_range = :custom
|
39
|
+
# Priority 3: Page-specific filters (chart zoom)
|
40
|
+
elsif ransack_params[:occurred_at_gteq].present? && ransack_params[:occurred_at_lt].present?
|
41
|
+
# Custom time range from chart zoom
|
42
|
+
start_time = parse_time_param(ransack_params[:occurred_at_gteq])
|
43
|
+
end_time = parse_time_param(ransack_params[:occurred_at_lt])
|
44
|
+
selected_time_range = :custom
|
45
|
+
# Priority 4: Global filters (from session)
|
46
|
+
elsif session_global_filters["start_time"].present? || session_global_filters["end_time"].present?
|
47
|
+
start_time = parse_time_param(session_global_filters["start_time"]) if session_global_filters["start_time"].present?
|
48
|
+
end_time = parse_time_param(session_global_filters["end_time"]) if session_global_filters["end_time"].present?
|
49
|
+
selected_time_range = :custom
|
34
50
|
end
|
51
|
+
# Priority 5: Default time range (already set above)
|
35
52
|
|
36
53
|
time_diff = (end_time.to_i - start_time.to_i) / 3600.0
|
37
54
|
|
@@ -43,7 +60,7 @@ module TimeRangeConcern
|
|
43
60
|
end_time = end_time.end_of_day
|
44
61
|
end
|
45
62
|
|
46
|
-
[ start_time, end_time, selected_time_range, time_diff ]
|
63
|
+
[ start_time.to_i, end_time.to_i, selected_time_range, time_diff ]
|
47
64
|
end
|
48
65
|
|
49
66
|
private
|
@@ -53,7 +70,9 @@ module TimeRangeConcern
|
|
53
70
|
when Time, DateTime
|
54
71
|
param.in_time_zone
|
55
72
|
when String
|
56
|
-
Time.zone
|
73
|
+
# Parse as server local time (not UTC, not Time.zone)
|
74
|
+
# This ensures flatpickr datetime strings are interpreted in server's timezone
|
75
|
+
Time.parse(param).localtime
|
57
76
|
else
|
58
77
|
# Assume it's an integer timestamp
|
59
78
|
Time.zone.at(param.to_i)
|
@@ -1,6 +1,8 @@
|
|
1
1
|
module RailsPulse
|
2
2
|
class ApplicationController < ActionController::Base
|
3
3
|
before_action :authenticate_rails_pulse_user!
|
4
|
+
before_action :set_show_non_tagged_default
|
5
|
+
helper_method :session_global_filters, :session_disabled_tags
|
4
6
|
|
5
7
|
def set_pagination_limit(limit = nil)
|
6
8
|
limit = limit || params[:limit]
|
@@ -12,6 +14,48 @@ module RailsPulse
|
|
12
14
|
end
|
13
15
|
end
|
14
16
|
|
17
|
+
def set_global_filters
|
18
|
+
if params[:clear] == "true"
|
19
|
+
session.delete(:global_filters)
|
20
|
+
else
|
21
|
+
filters = session[:global_filters] || {}
|
22
|
+
|
23
|
+
# Update time filters if provided
|
24
|
+
if params[:start_time].present? && params[:end_time].present?
|
25
|
+
filters["start_time"] = params[:start_time]
|
26
|
+
filters["end_time"] = params[:end_time]
|
27
|
+
end
|
28
|
+
|
29
|
+
# Update performance threshold if provided (or remove if empty)
|
30
|
+
if params[:performance_threshold].present?
|
31
|
+
filters["performance_threshold"] = params[:performance_threshold]
|
32
|
+
else
|
33
|
+
filters.delete("performance_threshold")
|
34
|
+
end
|
35
|
+
|
36
|
+
# Update tag visibility - convert enabled tags to disabled tags
|
37
|
+
all_tags = RailsPulse.configuration.tags
|
38
|
+
enabled_tags = params[:enabled_tags] || []
|
39
|
+
|
40
|
+
# Handle "non_tagged" separately
|
41
|
+
session[:show_non_tagged] = enabled_tags.include?("non_tagged")
|
42
|
+
enabled_tags = enabled_tags - [ "non_tagged" ]
|
43
|
+
|
44
|
+
disabled_tags = all_tags - enabled_tags
|
45
|
+
|
46
|
+
if disabled_tags.any?
|
47
|
+
filters["disabled_tags"] = disabled_tags
|
48
|
+
else
|
49
|
+
filters.delete("disabled_tags")
|
50
|
+
end
|
51
|
+
|
52
|
+
session[:global_filters] = filters
|
53
|
+
end
|
54
|
+
|
55
|
+
# Redirect back to the referring page or root
|
56
|
+
redirect_back(fallback_location: root_path)
|
57
|
+
end
|
58
|
+
|
15
59
|
private
|
16
60
|
|
17
61
|
def authenticate_rails_pulse_user!
|
@@ -71,5 +115,34 @@ module RailsPulse
|
|
71
115
|
validated_limit = limit.to_i.clamp(5, 50)
|
72
116
|
session[:pagination_limit] = validated_limit if limit.present?
|
73
117
|
end
|
118
|
+
|
119
|
+
def session_global_filters
|
120
|
+
session[:global_filters] || {}
|
121
|
+
end
|
122
|
+
|
123
|
+
def session_disabled_tags
|
124
|
+
session_global_filters["disabled_tags"] || []
|
125
|
+
end
|
126
|
+
|
127
|
+
# Get the minimum duration based on global performance threshold
|
128
|
+
# Returns nil if no threshold is set (show all)
|
129
|
+
# context: :route, :request, or :query
|
130
|
+
def global_performance_threshold_duration(context)
|
131
|
+
threshold = session_global_filters["performance_threshold"]
|
132
|
+
return nil unless threshold.present?
|
133
|
+
|
134
|
+
config_key = "#{context}_thresholds".to_sym
|
135
|
+
thresholds = RailsPulse.configuration.public_send(config_key)
|
136
|
+
|
137
|
+
thresholds[threshold.to_sym]
|
138
|
+
rescue StandardError => e
|
139
|
+
Rails.logger.warn "Failed to get performance threshold: #{e.message}"
|
140
|
+
nil
|
141
|
+
end
|
142
|
+
|
143
|
+
# Set default value for show_non_tagged if not already set
|
144
|
+
def set_show_non_tagged_default
|
145
|
+
session[:show_non_tagged] = true if session[:show_non_tagged].nil?
|
146
|
+
end
|
74
147
|
end
|
75
148
|
end
|
@@ -1,6 +1,7 @@
|
|
1
1
|
module RailsPulse
|
2
2
|
class QueriesController < ApplicationController
|
3
3
|
include ChartTableConcern
|
4
|
+
include TagFilterConcern
|
4
5
|
|
5
6
|
before_action :set_query, only: [ :show, :analyze ]
|
6
7
|
|
@@ -114,13 +115,15 @@ module RailsPulse
|
|
114
115
|
def build_table_results
|
115
116
|
if show_action?
|
116
117
|
# For Summary model on show page - ransack params already include query ID and type filters
|
118
|
+
# Summaries aren't taggable, so we don't apply tag filters here
|
117
119
|
@ransack_query.result.where(period_type: period_type)
|
118
120
|
else
|
119
121
|
Queries::Tables::Index.new(
|
120
122
|
ransack_query: @ransack_query,
|
121
123
|
period_type: period_type,
|
122
124
|
start_time: @start_time,
|
123
|
-
params: params
|
125
|
+
params: params,
|
126
|
+
disabled_tags: session_disabled_tags
|
124
127
|
).to_table
|
125
128
|
end
|
126
129
|
end
|
@@ -1,6 +1,14 @@
|
|
1
1
|
module RailsPulse
|
2
2
|
class RequestsController < ApplicationController
|
3
3
|
include ChartTableConcern
|
4
|
+
include TagFilterConcern
|
5
|
+
|
6
|
+
# Override TIME_RANGE_OPTIONS from TimeRangeConcern with requests-specific options
|
7
|
+
remove_const(:TIME_RANGE_OPTIONS) if const_defined?(:TIME_RANGE_OPTIONS)
|
8
|
+
TIME_RANGE_OPTIONS = [
|
9
|
+
[ "Recent", "recent" ],
|
10
|
+
[ "Custom Range", "custom" ]
|
11
|
+
].freeze
|
4
12
|
|
5
13
|
before_action :set_request, only: :show
|
6
14
|
|
@@ -55,11 +63,32 @@ module RailsPulse
|
|
55
63
|
end
|
56
64
|
|
57
65
|
def build_table_ransack_params(ransack_params)
|
58
|
-
params = ransack_params.
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
66
|
+
params = ransack_params.dup
|
67
|
+
|
68
|
+
# Handle time mode - check if recent mode is selected
|
69
|
+
time_mode = params[:period_start_range] || "recent"
|
70
|
+
|
71
|
+
if time_mode != "recent"
|
72
|
+
# Custom mode - apply time filters
|
73
|
+
params.merge!(
|
74
|
+
occurred_at_gteq: Time.at(@table_start_time),
|
75
|
+
occurred_at_lt: Time.at(@table_end_time)
|
76
|
+
)
|
77
|
+
end
|
78
|
+
# else: Recent mode - no time filters, just rely on sort + pagination
|
79
|
+
|
80
|
+
# Duration filter - convert symbol to numeric threshold or use @start_duration
|
81
|
+
if params[:duration_gteq].present?
|
82
|
+
# If it's a symbol like :slow, convert it to the numeric threshold
|
83
|
+
if params[:duration_gteq].to_s.in?(%w[slow very_slow critical])
|
84
|
+
params[:duration_gteq] = @start_duration
|
85
|
+
end
|
86
|
+
# else: it's already a number, keep it as is
|
87
|
+
elsif @start_duration && @start_duration > 0
|
88
|
+
# No duration_gteq param, use @start_duration from concern
|
89
|
+
params[:duration_gteq] = @start_duration
|
90
|
+
end
|
91
|
+
|
63
92
|
params
|
64
93
|
end
|
65
94
|
|
@@ -68,10 +97,13 @@ module RailsPulse
|
|
68
97
|
end
|
69
98
|
|
70
99
|
def build_table_results
|
71
|
-
base_query = @ransack_query.result.includes(:route)
|
100
|
+
base_query = apply_tag_filters(@ransack_query.result.includes(:route))
|
101
|
+
|
102
|
+
# If filtering or sorting by route_path, we need to join the routes table
|
103
|
+
needs_join = @ransack_query.sorts.any? { |sort| sort.name == "route_path" } ||
|
104
|
+
params.dig(:q, :route_path_cont).present?
|
72
105
|
|
73
|
-
|
74
|
-
if @ransack_query.sorts.any? { |sort| sort.name == "route_path" }
|
106
|
+
if needs_join
|
75
107
|
base_query = base_query.joins(:route)
|
76
108
|
end
|
77
109
|
|
@@ -1,6 +1,7 @@
|
|
1
1
|
module RailsPulse
|
2
2
|
class RoutesController < ApplicationController
|
3
3
|
include ChartTableConcern
|
4
|
+
include TagFilterConcern
|
4
5
|
|
5
6
|
before_action :set_route, only: :show
|
6
7
|
|
@@ -86,7 +87,7 @@ module RailsPulse
|
|
86
87
|
if show_action?
|
87
88
|
# Only show requests that belong to time periods where we have route summaries
|
88
89
|
# This ensures the table data is consistent with the chart data
|
89
|
-
base_query = @ransack_query.result
|
90
|
+
base_query = apply_tag_filters(@ransack_query.result)
|
90
91
|
.joins(<<~SQL)
|
91
92
|
INNER JOIN rails_pulse_summaries ON
|
92
93
|
rails_pulse_summaries.summarizable_id = rails_pulse_requests.route_id AND
|
@@ -108,7 +109,8 @@ module RailsPulse
|
|
108
109
|
ransack_query: @ransack_query,
|
109
110
|
period_type: period_type,
|
110
111
|
start_time: @start_time,
|
111
|
-
params: params
|
112
|
+
params: params,
|
113
|
+
disabled_tags: session_disabled_tags
|
112
114
|
).to_table
|
113
115
|
end
|
114
116
|
end
|
@@ -0,0 +1,51 @@
|
|
1
|
+
module RailsPulse
|
2
|
+
class TagsController < ApplicationController
|
3
|
+
before_action :set_taggable
|
4
|
+
|
5
|
+
def create
|
6
|
+
tag = params[:tag]
|
7
|
+
|
8
|
+
if tag.blank?
|
9
|
+
render turbo_stream: turbo_stream.replace("tag_manager_#{@taggable.class.name.demodulize.underscore}_#{@taggable.id}",
|
10
|
+
partial: "rails_pulse/tags/tag_manager",
|
11
|
+
locals: { taggable: @taggable, error: "Tag cannot be blank" })
|
12
|
+
return
|
13
|
+
end
|
14
|
+
|
15
|
+
@taggable.add_tag(tag)
|
16
|
+
@taggable.reload
|
17
|
+
|
18
|
+
render turbo_stream: turbo_stream.replace("tag_manager_#{@taggable.class.name.demodulize.underscore}_#{@taggable.id}",
|
19
|
+
partial: "rails_pulse/tags/tag_manager",
|
20
|
+
locals: { taggable: @taggable })
|
21
|
+
end
|
22
|
+
|
23
|
+
def destroy
|
24
|
+
tag = params[:tag]
|
25
|
+
@taggable.remove_tag(tag)
|
26
|
+
@taggable.reload
|
27
|
+
|
28
|
+
render turbo_stream: turbo_stream.replace("tag_manager_#{@taggable.class.name.demodulize.underscore}_#{@taggable.id}",
|
29
|
+
partial: "rails_pulse/tags/tag_manager",
|
30
|
+
locals: { taggable: @taggable })
|
31
|
+
end
|
32
|
+
|
33
|
+
private
|
34
|
+
|
35
|
+
def set_taggable
|
36
|
+
@taggable_type = params[:taggable_type]
|
37
|
+
@taggable_id = params[:taggable_id]
|
38
|
+
|
39
|
+
@taggable = case @taggable_type
|
40
|
+
when "route"
|
41
|
+
Route.find(@taggable_id)
|
42
|
+
when "request"
|
43
|
+
Request.find(@taggable_id)
|
44
|
+
when "query"
|
45
|
+
Query.find(@taggable_id)
|
46
|
+
else
|
47
|
+
head :not_found
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
@@ -7,6 +7,8 @@ module RailsPulse
|
|
7
7
|
include FormattingHelper
|
8
8
|
include StatusHelper
|
9
9
|
include TableHelper
|
10
|
+
include FormHelper
|
11
|
+
include TagsHelper
|
10
12
|
|
11
13
|
# Replacement for lucide_icon helper that works with pre-compiled assets
|
12
14
|
# Outputs a custom element that will be hydrated by Stimulus
|
@@ -0,0 +1,75 @@
|
|
1
|
+
module RailsPulse
|
2
|
+
module FormHelper
|
3
|
+
# Renders a time range selector that can switch between preset ranges and a custom datetime picker
|
4
|
+
#
|
5
|
+
# @param form [ActionView::Helpers::FormBuilder] The form builder instance
|
6
|
+
# @param time_range_options [Array] Array of [label, value] pairs for the select options
|
7
|
+
# @param selected_time_range [Symbol, String] Currently selected time range
|
8
|
+
# @param mode [Symbol] :preset (default) for preset time ranges, :recent_custom for Recent/Custom toggle
|
9
|
+
# @return [String] HTML for the time range selector
|
10
|
+
def time_range_selector(form, time_range_options:, selected_time_range:, mode: :preset)
|
11
|
+
global_filters = session_global_filters
|
12
|
+
has_global_date_range = global_filters["start_time"].present? && global_filters["end_time"].present?
|
13
|
+
global_date_range = has_global_date_range ? "#{global_filters["start_time"]} to #{global_filters["end_time"]}" : ""
|
14
|
+
show_custom_picker = selected_time_range.to_sym == :custom
|
15
|
+
custom_date_value = params.dig(:q, :custom_date_range) || (show_custom_picker && has_global_date_range ? global_date_range : "")
|
16
|
+
|
17
|
+
content_tag(:div, class: "time-range-selector", data: { mode: mode }) do
|
18
|
+
concat time_range_select_wrapper(form, time_range_options, selected_time_range, mode)
|
19
|
+
concat time_range_picker_wrapper(form, custom_date_value)
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
private
|
24
|
+
|
25
|
+
def time_range_select_wrapper(form, time_range_options, selected_time_range, mode)
|
26
|
+
content_tag(:div, data: { rails_pulse__custom_range_target: "selectWrapper" }, style: "min-width: 150px;") do
|
27
|
+
form.select :period_start_range,
|
28
|
+
time_range_options,
|
29
|
+
{ selected: selected_time_range },
|
30
|
+
{
|
31
|
+
class: "input",
|
32
|
+
data: {
|
33
|
+
action: "change->rails-pulse--custom-range#handleChange",
|
34
|
+
mode: mode
|
35
|
+
}
|
36
|
+
}
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
def time_range_picker_wrapper(form, custom_date_value)
|
41
|
+
content_tag(:div,
|
42
|
+
data: { rails_pulse__custom_range_target: "pickerWrapper" },
|
43
|
+
style: "display: none; position: relative; min-width: 360px;"
|
44
|
+
) do
|
45
|
+
concat time_range_picker_input(form, custom_date_value)
|
46
|
+
concat time_range_close_button
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
def time_range_picker_input(form, custom_date_value)
|
51
|
+
form.text_field :custom_date_range,
|
52
|
+
value: custom_date_value,
|
53
|
+
placeholder: "Pick date range",
|
54
|
+
class: "input",
|
55
|
+
style: "padding-inline-end: 2.5rem;",
|
56
|
+
data: {
|
57
|
+
controller: "rails-pulse--datepicker",
|
58
|
+
rails_pulse__datepicker_mode_value: "range",
|
59
|
+
rails_pulse__datepicker_show_months_value: 2,
|
60
|
+
rails_pulse__datepicker_type_value: "datetime"
|
61
|
+
}
|
62
|
+
end
|
63
|
+
|
64
|
+
def time_range_close_button
|
65
|
+
button_tag(
|
66
|
+
rails_pulse_icon("x", width: "18"),
|
67
|
+
type: "button",
|
68
|
+
class: "btn btn--borderless",
|
69
|
+
style: "position: absolute; inset-inline-end: 0; inset-block-start: 0; inset-block-end: 0; padding: 0.5rem; background: transparent; border: none;",
|
70
|
+
data: { action: "click->rails-pulse--custom-range#showSelect" },
|
71
|
+
aria: { label: "Close custom range" }
|
72
|
+
)
|
73
|
+
end
|
74
|
+
end
|
75
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
module RailsPulse
|
2
|
+
module TagsHelper
|
3
|
+
# Display tags as badge elements
|
4
|
+
# Accepts:
|
5
|
+
# - Taggable objects (with tag_list method)
|
6
|
+
# - Raw JSON strings from aggregated queries
|
7
|
+
# - Arrays of tags
|
8
|
+
def display_tag_badges(tags)
|
9
|
+
tag_array = case tags
|
10
|
+
when String
|
11
|
+
# Parse JSON string from database
|
12
|
+
begin
|
13
|
+
JSON.parse(tags)
|
14
|
+
rescue JSON::ParserError
|
15
|
+
[]
|
16
|
+
end
|
17
|
+
when Array
|
18
|
+
tags
|
19
|
+
else
|
20
|
+
# Handle Taggable objects
|
21
|
+
tags.respond_to?(:tag_list) ? tags.tag_list : []
|
22
|
+
end
|
23
|
+
|
24
|
+
return content_tag(:span, "-", class: "text-subtle") if tag_array.empty?
|
25
|
+
|
26
|
+
safe_join(tag_array.map { |tag| content_tag(:div, tag, class: "badge") }, " ")
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
@@ -5,6 +5,7 @@ import { Application } from "@hotwired/stimulus";
|
|
5
5
|
|
6
6
|
// CSS Zero Controllers
|
7
7
|
import ContextMenuController from "./controllers/context_menu_controller";
|
8
|
+
import DatePickerController from "./controllers/datepicker_controller";
|
8
9
|
import DialogController from "./controllers/dialog_controller";
|
9
10
|
import MenuController from "./controllers/menu_controller";
|
10
11
|
import PopoverController from "./controllers/popover_controller";
|
@@ -19,6 +20,8 @@ import IconController from "./controllers/icon_controller";
|
|
19
20
|
import ExpandableRowsController from "./controllers/expandable_rows_controller";
|
20
21
|
import CollapsibleController from "./controllers/collapsible_controller";
|
21
22
|
import TableSortController from "./controllers/table_sort_controller";
|
23
|
+
import GlobalFiltersController from "./controllers/global_filters_controller";
|
24
|
+
import CustomRangeController from "./controllers/custom_range_controller";
|
22
25
|
|
23
26
|
const application = Application.start();
|
24
27
|
|
@@ -33,6 +36,7 @@ window.echarts = echarts;
|
|
33
36
|
window.Turbo = Turbo;
|
34
37
|
|
35
38
|
application.register("rails-pulse--context-menu", ContextMenuController);
|
39
|
+
application.register("rails-pulse--datepicker", DatePickerController);
|
36
40
|
application.register("rails-pulse--dialog", DialogController);
|
37
41
|
application.register("rails-pulse--menu", MenuController);
|
38
42
|
application.register("rails-pulse--popover", PopoverController);
|
@@ -46,6 +50,8 @@ application.register("rails-pulse--icon", IconController);
|
|
46
50
|
application.register("rails-pulse--expandable-rows", ExpandableRowsController);
|
47
51
|
application.register("rails-pulse--collapsible", CollapsibleController);
|
48
52
|
application.register("rails-pulse--table-sort", TableSortController);
|
53
|
+
application.register("rails-pulse--global-filters", GlobalFiltersController);
|
54
|
+
application.register("rails-pulse--custom-range", CustomRangeController);
|
49
55
|
|
50
56
|
// Ensure Turbo Frames are loaded after page load
|
51
57
|
document.addEventListener('DOMContentLoaded', () => {
|
@@ -0,0 +1,115 @@
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
2
|
+
|
3
|
+
export default class extends Controller {
|
4
|
+
static targets = ["selectWrapper", "pickerWrapper"]
|
5
|
+
|
6
|
+
connect() {
|
7
|
+
const selectElement = this.selectWrapperTarget.querySelector('select')
|
8
|
+
if (!selectElement) return
|
9
|
+
|
10
|
+
// Check if we're in recent_custom mode
|
11
|
+
const mode = selectElement.dataset.mode
|
12
|
+
|
13
|
+
if (mode === 'recent_custom') {
|
14
|
+
// In recent_custom mode, "recent" shows no picker, "custom" shows picker
|
15
|
+
if (selectElement.value === "custom") {
|
16
|
+
this.showPicker()
|
17
|
+
this.initializeDatePicker()
|
18
|
+
}
|
19
|
+
} else {
|
20
|
+
// In preset mode, "custom" shows picker
|
21
|
+
if (selectElement.value === "custom") {
|
22
|
+
this.showPicker()
|
23
|
+
this.initializeDatePicker()
|
24
|
+
}
|
25
|
+
}
|
26
|
+
}
|
27
|
+
|
28
|
+
// When time range is selected from dropdown
|
29
|
+
handleChange(event) {
|
30
|
+
const mode = event.target.dataset.mode
|
31
|
+
|
32
|
+
if (mode === 'recent_custom') {
|
33
|
+
// In recent_custom mode
|
34
|
+
if (event.target.value === "custom") {
|
35
|
+
this.showPicker()
|
36
|
+
this.openDatePicker()
|
37
|
+
} else {
|
38
|
+
// "recent" is selected - hide picker
|
39
|
+
this.pickerWrapperTarget.style.display = "none"
|
40
|
+
this.selectWrapperTarget.style.display = "block"
|
41
|
+
}
|
42
|
+
} else {
|
43
|
+
// In preset mode
|
44
|
+
if (event.target.value === "custom") {
|
45
|
+
this.showPicker()
|
46
|
+
this.openDatePicker()
|
47
|
+
}
|
48
|
+
}
|
49
|
+
}
|
50
|
+
|
51
|
+
// Show picker, hide select
|
52
|
+
showPicker() {
|
53
|
+
this.selectWrapperTarget.style.display = "none"
|
54
|
+
this.pickerWrapperTarget.style.display = "flex"
|
55
|
+
}
|
56
|
+
|
57
|
+
// Open the flatpickr calendar
|
58
|
+
openDatePicker() {
|
59
|
+
// Wait a bit for the DOM to update and flatpickr to initialize
|
60
|
+
setTimeout(() => {
|
61
|
+
// Find the original hidden input that has the datepicker controller
|
62
|
+
const hiddenInput = this.pickerWrapperTarget.querySelector('input[name*="custom_date_range"]')
|
63
|
+
if (!hiddenInput) return
|
64
|
+
|
65
|
+
// Get the datepicker controller from the hidden input
|
66
|
+
const datepickerController = this.application.getControllerForElementAndIdentifier(
|
67
|
+
hiddenInput,
|
68
|
+
'rails-pulse--datepicker'
|
69
|
+
)
|
70
|
+
|
71
|
+
if (datepickerController && datepickerController.flatpickr) {
|
72
|
+
datepickerController.flatpickr.open()
|
73
|
+
}
|
74
|
+
}, 50)
|
75
|
+
}
|
76
|
+
|
77
|
+
// Show select, hide picker
|
78
|
+
showSelect() {
|
79
|
+
this.pickerWrapperTarget.style.display = "none"
|
80
|
+
this.selectWrapperTarget.style.display = "block"
|
81
|
+
|
82
|
+
// Reset select to default value based on mode
|
83
|
+
const selectElement = this.selectWrapperTarget.querySelector('select')
|
84
|
+
if (selectElement) {
|
85
|
+
const mode = selectElement.dataset.mode
|
86
|
+
if (mode === 'recent_custom') {
|
87
|
+
selectElement.value = "recent"
|
88
|
+
} else {
|
89
|
+
selectElement.value = "last_day"
|
90
|
+
}
|
91
|
+
}
|
92
|
+
}
|
93
|
+
|
94
|
+
// Initialize flatpickr with existing date value
|
95
|
+
initializeDatePicker() {
|
96
|
+
const dateInput = this.pickerWrapperTarget.querySelector('input[type="text"]')
|
97
|
+
if (!dateInput || !dateInput.value) return
|
98
|
+
|
99
|
+
// Get the datepicker controller
|
100
|
+
const datepickerController = this.application.getControllerForElementAndIdentifier(
|
101
|
+
dateInput,
|
102
|
+
'rails-pulse--datepicker'
|
103
|
+
)
|
104
|
+
|
105
|
+
if (datepickerController && datepickerController.flatpickr) {
|
106
|
+
const value = dateInput.value
|
107
|
+
// Parse the "start to end" format
|
108
|
+
if (value.includes(' to ')) {
|
109
|
+
const [start, end] = value.split(' to ').map(d => d.trim())
|
110
|
+
// Set the dates in flatpickr
|
111
|
+
datepickerController.flatpickr.setDate([start, end], false)
|
112
|
+
}
|
113
|
+
}
|
114
|
+
}
|
115
|
+
}
|
@@ -0,0 +1,48 @@
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
2
|
+
import flatpickr from "flatpickr"
|
3
|
+
|
4
|
+
export default class extends Controller {
|
5
|
+
static targets = [ "details" ]
|
6
|
+
static values = {
|
7
|
+
type: String, disable: Array,
|
8
|
+
mode: { type: String, default: "single" },
|
9
|
+
showMonths: { type: Number, default: 1 },
|
10
|
+
dateFormat: { type: String, default: "F d, Y" },
|
11
|
+
dateTimeFormat: { type: String, default: "M d, Y h:i K" }
|
12
|
+
}
|
13
|
+
|
14
|
+
connect() {
|
15
|
+
if (this.typeValue == "time") {
|
16
|
+
this.flatpickr = flatpickr(this.element, this.#timeOptions)
|
17
|
+
} else if (this.typeValue == "datetime") {
|
18
|
+
this.flatpickr = flatpickr(this.element, this.#dateTimeOptions)
|
19
|
+
} else {
|
20
|
+
this.flatpickr = flatpickr(this.element, this.#basicOptions)
|
21
|
+
}
|
22
|
+
}
|
23
|
+
|
24
|
+
disconnect() {
|
25
|
+
this.flatpickr.destroy()
|
26
|
+
}
|
27
|
+
|
28
|
+
get #timeOptions() {
|
29
|
+
return { dateFormat: "H:i", enableTime: true, noCalendar: true }
|
30
|
+
}
|
31
|
+
|
32
|
+
get #dateTimeOptions() {
|
33
|
+
return { ...this.#baseOptions, altFormat: this.dateTimeFormatValue, dateFormat: "Y-m-d H:i", enableTime: true }
|
34
|
+
}
|
35
|
+
|
36
|
+
get #basicOptions() {
|
37
|
+
return { ...this.#baseOptions, altFormat: this.dateFormatValue, dateFormat: "Y-m-d" }
|
38
|
+
}
|
39
|
+
|
40
|
+
get #baseOptions() {
|
41
|
+
return {
|
42
|
+
altInput: true,
|
43
|
+
disable: this.disableValue,
|
44
|
+
mode: this.modeValue,
|
45
|
+
showMonths: this.showMonthsValue
|
46
|
+
}
|
47
|
+
}
|
48
|
+
}
|