rails_visualizer 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/CHANGELOG.md +36 -0
- data/LICENSE.txt +21 -0
- data/README.md +509 -0
- data/lib/rails_visualizer/assets/dist/index.html +76 -0
- data/lib/rails_visualizer/configuration.rb +35 -0
- data/lib/rails_visualizer/controllers_inspector.rb +200 -0
- data/lib/rails_visualizer/gems_inspector.rb +105 -0
- data/lib/rails_visualizer/introspector.rb +191 -0
- data/lib/rails_visualizer/jobs_inspector.rb +120 -0
- data/lib/rails_visualizer/mailers_inspector.rb +126 -0
- data/lib/rails_visualizer/migrations_inspector.rb +68 -0
- data/lib/rails_visualizer/path_helper.rb +29 -0
- data/lib/rails_visualizer/railtie.rb +9 -0
- data/lib/rails_visualizer/renderer.rb +31 -0
- data/lib/rails_visualizer/routes_inspector.rb +124 -0
- data/lib/rails_visualizer/schema/cache.rb +234 -0
- data/lib/rails_visualizer/schema/index_inspector.rb +43 -0
- data/lib/rails_visualizer/schema/model_inspector.rb +245 -0
- data/lib/rails_visualizer/serializer.rb +124 -0
- data/lib/rails_visualizer/version.rb +5 -0
- data/lib/rails_visualizer.rb +22 -0
- data/lib/tasks/rails_visualizer.rake +59 -0
- metadata +76 -0
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsVisualizer
|
|
4
|
+
class Configuration
|
|
5
|
+
attr_accessor :excluded_models, :excluded_controllers, :excluded_jobs, :excluded_mailers,
|
|
6
|
+
:excluded_route_paths, :output_path, :open_browser, :theme, :parallel, :filename
|
|
7
|
+
|
|
8
|
+
def initialize
|
|
9
|
+
@excluded_models = []
|
|
10
|
+
@excluded_controllers = []
|
|
11
|
+
@excluded_jobs = []
|
|
12
|
+
@excluded_mailers = []
|
|
13
|
+
@excluded_route_paths = []
|
|
14
|
+
@output_path = 'tmp/rails_visualizer'
|
|
15
|
+
@open_browser = true
|
|
16
|
+
@theme = :light
|
|
17
|
+
@parallel = true
|
|
18
|
+
@filename = 'index.html'
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
class << self
|
|
23
|
+
def configuration
|
|
24
|
+
@configuration ||= Configuration.new
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def configure
|
|
28
|
+
yield(configuration)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def reset_configuration!
|
|
32
|
+
@configuration = Configuration.new
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsVisualizer
|
|
4
|
+
class ControllersInspector
|
|
5
|
+
include RailsVisualizer::PathHelper
|
|
6
|
+
|
|
7
|
+
FRAMEWORK_NAMESPACES = %w[
|
|
8
|
+
ActionController::
|
|
9
|
+
ActionDispatch::
|
|
10
|
+
ActiveStorage::
|
|
11
|
+
ActionMailbox::
|
|
12
|
+
ActionText::
|
|
13
|
+
Rails::
|
|
14
|
+
Turbo::
|
|
15
|
+
Devise::
|
|
16
|
+
].freeze
|
|
17
|
+
|
|
18
|
+
ACTION_FILTER_CLASS = 'AbstractController::Callbacks::ActionFilter'
|
|
19
|
+
ROUTABLE_ACTION_PATTERN = /\A[a-z_][a-zA-Z0-9_]*!?\z/
|
|
20
|
+
|
|
21
|
+
def initialize(excluded: [])
|
|
22
|
+
@excluded_set = excluded.to_set
|
|
23
|
+
@callbacks_by_class = {}
|
|
24
|
+
@callback_origin_cache = {}
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def call
|
|
28
|
+
return [] unless defined?(ActionController::Base)
|
|
29
|
+
|
|
30
|
+
base = ActionController::Base.descendants
|
|
31
|
+
api = defined?(ActionController::API) ? ActionController::API.descendants : []
|
|
32
|
+
|
|
33
|
+
(base + api)
|
|
34
|
+
.uniq
|
|
35
|
+
.reject { |c| c.name.nil? || framework_class?(c.name) || @excluded_set.include?(c.name) }
|
|
36
|
+
.map { |c| serialize_controller(c) }
|
|
37
|
+
.sort_by { |c| c[:name] }
|
|
38
|
+
rescue StandardError
|
|
39
|
+
[]
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def serialize_controller(controller)
|
|
45
|
+
chain = callbacks_for(controller)
|
|
46
|
+
sc_chain = callbacks_for(controller.superclass)
|
|
47
|
+
|
|
48
|
+
# Partition the chain once — avoids iterating it 4+ times.
|
|
49
|
+
by_kind, cb_names = partition_chain(chain)
|
|
50
|
+
sc_by_kind = partition_chain(sc_chain).first
|
|
51
|
+
|
|
52
|
+
{
|
|
53
|
+
name: controller.name,
|
|
54
|
+
superclass: controller.superclass.name,
|
|
55
|
+
namespace: extract_namespace(controller.name),
|
|
56
|
+
file_path: source_path_for(controller),
|
|
57
|
+
controller_path: controller.controller_path,
|
|
58
|
+
actions: user_actions(controller),
|
|
59
|
+
public_helpers: public_helpers(controller, cb_names),
|
|
60
|
+
before_actions: build_kind(controller, :before, by_kind, sc_by_kind),
|
|
61
|
+
after_actions: build_kind(controller, :after, by_kind, sc_by_kind),
|
|
62
|
+
around_actions: build_kind(controller, :around, by_kind, sc_by_kind)
|
|
63
|
+
}
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Cached globally by class — many sibling controllers share the same superclass.
|
|
67
|
+
def callbacks_for(klass)
|
|
68
|
+
@callbacks_by_class[klass] ||= klass.send(:_process_action_callbacks).to_a
|
|
69
|
+
rescue StandardError
|
|
70
|
+
@callbacks_by_class[klass] = []
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Single pass: partitions symbol callbacks into { kind => [cb, ...] }
|
|
74
|
+
# and collects all callback names into a Set (for helper exclusion).
|
|
75
|
+
def partition_chain(chain)
|
|
76
|
+
by_kind = Hash.new { |h, k| h[k] = [] }
|
|
77
|
+
cb_names = Set.new
|
|
78
|
+
chain.each do |cb|
|
|
79
|
+
next unless cb.filter.is_a?(Symbol)
|
|
80
|
+
|
|
81
|
+
cb_names.add(cb.filter.to_s)
|
|
82
|
+
by_kind[cb.kind] << cb
|
|
83
|
+
end
|
|
84
|
+
[by_kind, cb_names]
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def build_kind(controller, kind, by_kind, sc_by_kind)
|
|
88
|
+
sc_names = sc_by_kind[kind].to_set { |cb| cb.filter.to_s }
|
|
89
|
+
|
|
90
|
+
raw = by_kind[kind].filter_map do |cb|
|
|
91
|
+
name = cb.filter.to_s
|
|
92
|
+
inherited = sc_names.include?(name) ? callback_origin(controller, name, kind) : nil
|
|
93
|
+
serialize_callback(cb, inherited)
|
|
94
|
+
end
|
|
95
|
+
raw.uniq { |c| c[:name] }
|
|
96
|
+
rescue StandardError
|
|
97
|
+
[]
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Methods physically written in the controller's own source file.
|
|
101
|
+
# Checks both owner (excludes plain module includes) and source_location
|
|
102
|
+
# (excludes methods injected via concern `included do` blocks).
|
|
103
|
+
def own_public_methods(controller)
|
|
104
|
+
ctrl_file = controller_source_file(controller)
|
|
105
|
+
controller.public_instance_methods(false).filter_map do |m|
|
|
106
|
+
um = controller.instance_method(m)
|
|
107
|
+
next unless um.owner == controller
|
|
108
|
+
next if ctrl_file && um.source_location&.first != ctrl_file
|
|
109
|
+
|
|
110
|
+
m.to_s
|
|
111
|
+
end
|
|
112
|
+
rescue StandardError
|
|
113
|
+
[]
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def controller_source_file(controller)
|
|
117
|
+
Module.const_source_location(controller.name)&.first
|
|
118
|
+
rescue StandardError
|
|
119
|
+
nil
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def user_actions(controller)
|
|
123
|
+
own_public_methods(controller)
|
|
124
|
+
.grep(ROUTABLE_ACTION_PATTERN)
|
|
125
|
+
.sort
|
|
126
|
+
.map { |name| { name: name, defined_in: nil } }
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def public_helpers(controller, cb_names)
|
|
130
|
+
own_public_methods(controller)
|
|
131
|
+
.reject { |m| ROUTABLE_ACTION_PATTERN.match?(m) || cb_names.include?(m) }
|
|
132
|
+
.sort
|
|
133
|
+
.map { |m| { name: m, defined_in: nil } }
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def extract_namespace(name)
|
|
137
|
+
parts = name.split('::')
|
|
138
|
+
parts.size > 1 ? parts[0..-2].join('::') : nil
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def callback_origin(controller, filter_name, kind)
|
|
142
|
+
key = [controller.superclass, filter_name, kind]
|
|
143
|
+
@callback_origin_cache.fetch(key) do
|
|
144
|
+
@callback_origin_cache[key] = compute_callback_origin(controller, filter_name, kind)
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def compute_callback_origin(controller, filter_name, kind)
|
|
149
|
+
origin = nil
|
|
150
|
+
klass = controller.superclass
|
|
151
|
+
while user_controller_ancestor?(klass)
|
|
152
|
+
cbs = callbacks_for(klass)
|
|
153
|
+
origin = klass.name if cbs.any? { |cb| cb.kind == kind && cb.filter.to_s == filter_name }
|
|
154
|
+
klass = klass.superclass
|
|
155
|
+
end
|
|
156
|
+
origin
|
|
157
|
+
rescue StandardError
|
|
158
|
+
nil
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# True for any user-defined ancestor that can hold action callbacks.
|
|
162
|
+
# Handles both ActionController::Base and ActionController::API hierarchies.
|
|
163
|
+
def user_controller_ancestor?(klass)
|
|
164
|
+
return false unless klass.is_a?(Class)
|
|
165
|
+
|
|
166
|
+
(klass < ActionController::Base) ||
|
|
167
|
+
(defined?(ActionController::API) && klass < ActionController::API)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def serialize_callback(callback, inherited_from = nil)
|
|
171
|
+
if_items = Array(callback.instance_variable_get(:@if))
|
|
172
|
+
unless_items = Array(callback.instance_variable_get(:@unless))
|
|
173
|
+
|
|
174
|
+
{
|
|
175
|
+
name: callback.filter.to_s,
|
|
176
|
+
only: action_filter_actions(if_items),
|
|
177
|
+
except: action_filter_actions(unless_items),
|
|
178
|
+
if_conditions: if_items.grep(Symbol).map(&:to_s),
|
|
179
|
+
unless_conditions: unless_items.grep(Symbol).map(&:to_s),
|
|
180
|
+
has_custom_condition: (if_items + unless_items).any?(Proc),
|
|
181
|
+
inherited_from: inherited_from
|
|
182
|
+
}
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def action_filter_actions(items)
|
|
186
|
+
items.each_with_object([]) do |item, result|
|
|
187
|
+
next unless item.class.name == ACTION_FILTER_CLASS # rubocop:disable Style/ClassEqualityComparison
|
|
188
|
+
|
|
189
|
+
raw = item.instance_variable_get(:@actions)
|
|
190
|
+
result.concat(Array(raw).map(&:to_s).sort)
|
|
191
|
+
end
|
|
192
|
+
rescue StandardError
|
|
193
|
+
[]
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def framework_class?(name)
|
|
197
|
+
FRAMEWORK_NAMESPACES.any? { |ns| name.start_with?(ns) }
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
end
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsVisualizer
|
|
4
|
+
class GemsInspector
|
|
5
|
+
# Groups Bundler adds implicitly when no :group is specified — not user-defined.
|
|
6
|
+
IMPLICIT_GROUPS = %w[default].freeze
|
|
7
|
+
|
|
8
|
+
def call
|
|
9
|
+
return [] unless defined?(Bundler)
|
|
10
|
+
|
|
11
|
+
specs_map = build_specs_map
|
|
12
|
+
Bundler.definition.dependencies
|
|
13
|
+
.map { |dep| serialize_gem(dep, specs_map[dep.name]) }
|
|
14
|
+
.sort_by { |g| g[:name] }
|
|
15
|
+
rescue StandardError
|
|
16
|
+
[]
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def build_specs_map
|
|
22
|
+
Bundler.load.specs.each_with_object({}) do |spec, map|
|
|
23
|
+
map[spec.name] ||= spec
|
|
24
|
+
end
|
|
25
|
+
rescue StandardError
|
|
26
|
+
{}
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def serialize_gem(dep, spec)
|
|
30
|
+
{
|
|
31
|
+
name: dep.name,
|
|
32
|
+
version: spec&.version.to_s.presence,
|
|
33
|
+
requirement: dep.requirement.to_s,
|
|
34
|
+
groups: normalize_groups(dep.groups),
|
|
35
|
+
source_tags: source_tags(dep, spec) + require_tags(dep),
|
|
36
|
+
summary: clean_summary(spec&.summary),
|
|
37
|
+
homepage: spec&.homepage.to_s.presence
|
|
38
|
+
}
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Returns explicit environment groups only. Gems with no explicit group
|
|
42
|
+
# (Bundler's implicit :default) return [] — the UI treats them as "Common".
|
|
43
|
+
def normalize_groups(groups)
|
|
44
|
+
groups.map(&:to_s).reject { |g| IMPLICIT_GROUPS.include?(g) }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def clean_summary(summary)
|
|
48
|
+
str = summary.to_s.strip
|
|
49
|
+
str.empty? ? nil : str
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Builds an array of human-readable parameter chips for non-rubygems sources,
|
|
53
|
+
# e.g. ["github: procore-oss/migration-lock-timeout", "branch: main", "path: engines/erp"].
|
|
54
|
+
def source_tags(dep, spec)
|
|
55
|
+
src = dep.source || spec&.source
|
|
56
|
+
return [] unless src
|
|
57
|
+
|
|
58
|
+
if src.is_a?(Bundler::Source::Git)
|
|
59
|
+
git_source_tags(src)
|
|
60
|
+
elsif src.is_a?(Bundler::Source::Path)
|
|
61
|
+
["path: #{src.path}"]
|
|
62
|
+
else
|
|
63
|
+
[]
|
|
64
|
+
end
|
|
65
|
+
rescue StandardError
|
|
66
|
+
[]
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def git_source_tags(src)
|
|
70
|
+
tags = [git_origin_tag(src.uri.to_s)]
|
|
71
|
+
tags << "branch: #{src.branch}" if src.branch
|
|
72
|
+
tags << "tag: #{src.tag}" if src.tag
|
|
73
|
+
# Only surface a bare ref if no branch/tag was given (pinned SHA)
|
|
74
|
+
tags << "ref: #{src.ref[0..7]}" if src.ref && !src.branch && !src.tag
|
|
75
|
+
tags.compact
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Builds chips for the require: option.
|
|
79
|
+
# require: false → ["require: false"]
|
|
80
|
+
# require: 'exifr/jpeg' → ["require: exifr/jpeg"]
|
|
81
|
+
# require: [a, b] → ["require: a, b"]
|
|
82
|
+
# (not specified) → []
|
|
83
|
+
def require_tags(dep)
|
|
84
|
+
ar = dep.autorequire
|
|
85
|
+
return [] if ar.nil? # no require: key at all → default behaviour
|
|
86
|
+
return ['require: false'] if ar.empty?
|
|
87
|
+
|
|
88
|
+
paths = Array(ar).map(&:to_s).reject(&:empty?)
|
|
89
|
+
paths.empty? ? [] : ["require: #{paths.join(', ')}"]
|
|
90
|
+
rescue StandardError
|
|
91
|
+
[]
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Converts a git URI to a readable "github: owner/repo" or "git: <uri>" tag.
|
|
95
|
+
def git_origin_tag(uri)
|
|
96
|
+
github = uri.match(%r{github\.com[/:](.+?)(?:\.git)?$})
|
|
97
|
+
return "github: #{github[1]}" if github
|
|
98
|
+
|
|
99
|
+
gitlab = uri.match(%r{gitlab\.com[/:](.+?)(?:\.git)?$})
|
|
100
|
+
return "gitlab: #{gitlab[1]}" if gitlab
|
|
101
|
+
|
|
102
|
+
"git: #{uri}"
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsVisualizer
|
|
4
|
+
class Introspector
|
|
5
|
+
include RailsVisualizer::PathHelper
|
|
6
|
+
|
|
7
|
+
def initialize(configuration = RailsVisualizer.configuration)
|
|
8
|
+
@configuration = configuration
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def call
|
|
12
|
+
eager_load!
|
|
13
|
+
futures = launch_parallel_inspectors
|
|
14
|
+
cache, ar_models, migrations = run_db_phase
|
|
15
|
+
thread_results = join_futures(futures)
|
|
16
|
+
models = run_model_inspection(cache, ar_models)
|
|
17
|
+
|
|
18
|
+
assemble(models, migrations, thread_results)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def launch_parallel_inspectors
|
|
24
|
+
config = @configuration
|
|
25
|
+
{
|
|
26
|
+
routes: Thread.new { RoutesInspector.new(excluded_paths: config.excluded_route_paths).call },
|
|
27
|
+
jobs: Thread.new { JobsInspector.new(excluded: config.excluded_jobs).call },
|
|
28
|
+
controllers: Thread.new { ControllersInspector.new(excluded: config.excluded_controllers).call },
|
|
29
|
+
mailers: Thread.new { MailersInspector.new(excluded: config.excluded_mailers).call },
|
|
30
|
+
gems: Thread.new { GemsInspector.new.call }
|
|
31
|
+
}
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def run_db_phase
|
|
35
|
+
cache = Schema::Cache.new
|
|
36
|
+
ar_models = collect_models
|
|
37
|
+
table_names = ar_models.filter_map { |m| safe_table_name(m) }.uniq
|
|
38
|
+
cache.preload_for(table_names)
|
|
39
|
+
migrations = MigrationsInspector.new.call
|
|
40
|
+
[cache, ar_models, migrations]
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def join_futures(futures)
|
|
44
|
+
futures.transform_values(&:value)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def run_model_inspection(cache, ar_models)
|
|
48
|
+
file_cache = {}
|
|
49
|
+
preload_source_files(ar_models, file_cache)
|
|
50
|
+
inspect_models_parallel(cache, ar_models, file_cache)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def assemble(models, migrations, thread_results)
|
|
54
|
+
result = { app_name: app_name, models: models, migrations: migrations }
|
|
55
|
+
thread_results.each { |k, v| result[k] = v }
|
|
56
|
+
result
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# ------------------------------------------------------------------
|
|
60
|
+
# Source file + primary_key warmup (runs before fork)
|
|
61
|
+
# ------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
def preload_source_files(ar_models, file_cache)
|
|
64
|
+
root = app_root
|
|
65
|
+
ar_models.each do |model|
|
|
66
|
+
path = source_path_for(model)
|
|
67
|
+
next unless path
|
|
68
|
+
next if file_cache.key?(path)
|
|
69
|
+
|
|
70
|
+
absolute = File.join(root, path)
|
|
71
|
+
file_cache[path] = File.exist?(absolute) ? File.read(absolute) : nil
|
|
72
|
+
rescue StandardError
|
|
73
|
+
file_cache[path] = nil if defined?(path) && path
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# ------------------------------------------------------------------
|
|
78
|
+
# Fork-based parallel model inspection
|
|
79
|
+
# ------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
def inspect_models_parallel(cache, ar_models, file_cache)
|
|
82
|
+
nworkers = determine_worker_count(ar_models.size)
|
|
83
|
+
return inspect_sequential(cache, ar_models, file_cache) if nworkers <= 1
|
|
84
|
+
|
|
85
|
+
fork_and_collect(cache, ar_models, file_cache, nworkers)
|
|
86
|
+
rescue NotImplementedError
|
|
87
|
+
inspect_sequential(cache, ar_models, file_cache)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def inspect_sequential(cache, ar_models, file_cache)
|
|
91
|
+
method_cache = {}
|
|
92
|
+
ar_models.map { |m| Schema::ModelInspector.new(m, cache, file_cache, method_cache).call }
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def fork_and_collect(cache, ar_models, file_cache, nworkers)
|
|
96
|
+
slices = ar_models.each_slice((ar_models.size.to_f / nworkers).ceil).to_a
|
|
97
|
+
workers = slices.map.with_index do |slice, idx|
|
|
98
|
+
tmppath = File.join(Dir.tmpdir, "rails_visualizer_#{Process.pid}_#{idx}.dat")
|
|
99
|
+
pid = fork_worker(cache, slice, file_cache, tmppath)
|
|
100
|
+
[pid, tmppath]
|
|
101
|
+
end
|
|
102
|
+
collect_results(workers)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def fork_worker(cache, models, file_cache, tmppath)
|
|
106
|
+
Process.fork do
|
|
107
|
+
ActiveRecord::Base.connection_handler.clear_all_connections! rescue nil # rubocop:disable Style/RescueModifier
|
|
108
|
+
method_cache = {}
|
|
109
|
+
results = models.map do |m|
|
|
110
|
+
Schema::ModelInspector.new(m, cache, file_cache, method_cache).call
|
|
111
|
+
end
|
|
112
|
+
File.binwrite(tmppath, Marshal.dump(results))
|
|
113
|
+
rescue StandardError => e
|
|
114
|
+
warn "[RailsVisualizer] Fork worker error: #{e.message}"
|
|
115
|
+
File.binwrite(tmppath, Marshal.dump([]))
|
|
116
|
+
ensure
|
|
117
|
+
exit!(0) # rubocop:disable Rails/Exit
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def collect_results(workers)
|
|
122
|
+
workers.flat_map do |pid, tmppath|
|
|
123
|
+
_, status = Process.waitpid2(pid)
|
|
124
|
+
next [] unless status.success? && File.exist?(tmppath)
|
|
125
|
+
|
|
126
|
+
Marshal.load(File.binread(tmppath)) # rubocop:disable Security/MarshalLoad
|
|
127
|
+
ensure
|
|
128
|
+
File.delete(tmppath) if tmppath && File.exist?(tmppath)
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def determine_worker_count(model_count)
|
|
133
|
+
return 1 unless @configuration.parallel
|
|
134
|
+
return 1 unless Process.respond_to?(:fork)
|
|
135
|
+
return 1 if model_count < 50
|
|
136
|
+
|
|
137
|
+
require 'etc'
|
|
138
|
+
[Etc.nprocessors, 8, (model_count / 50.0).ceil].min
|
|
139
|
+
rescue StandardError
|
|
140
|
+
1
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# ------------------------------------------------------------------
|
|
144
|
+
# Existing helpers
|
|
145
|
+
# ------------------------------------------------------------------
|
|
146
|
+
|
|
147
|
+
def eager_load!
|
|
148
|
+
return unless defined?(Rails) && Rails.respond_to?(:application) && Rails.application
|
|
149
|
+
|
|
150
|
+
Rails.application.eager_load!
|
|
151
|
+
rescue StandardError => e
|
|
152
|
+
warn "[RailsVisualizer] Warning: eager_load! raised #{e.class}: #{e.message}"
|
|
153
|
+
nil
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def app_name
|
|
157
|
+
Rails.application.class.module_parent_name
|
|
158
|
+
rescue StandardError
|
|
159
|
+
nil
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def collect_models
|
|
163
|
+
ActiveRecord::Base.descendants.reject do |model|
|
|
164
|
+
model.abstract_class? ||
|
|
165
|
+
excluded?(model) ||
|
|
166
|
+
!model.name ||
|
|
167
|
+
anonymous?(model)
|
|
168
|
+
end
|
|
169
|
+
rescue NameError
|
|
170
|
+
[]
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def excluded?(model)
|
|
174
|
+
excluded_set.include?(model.name)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def excluded_set
|
|
178
|
+
@excluded_set ||= @configuration.excluded_models.to_set
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def anonymous?(model)
|
|
182
|
+
model.name.start_with?('#<')
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def safe_table_name(model)
|
|
186
|
+
model.table_name
|
|
187
|
+
rescue StandardError
|
|
188
|
+
nil
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
end
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsVisualizer
|
|
4
|
+
class JobsInspector
|
|
5
|
+
include RailsVisualizer::PathHelper
|
|
6
|
+
|
|
7
|
+
# Namespaces whose jobs come from Rails or popular gems, not user code.
|
|
8
|
+
FRAMEWORK_NAMESPACES = %w[
|
|
9
|
+
ActiveJob::
|
|
10
|
+
ActionMailer::
|
|
11
|
+
ActiveStorage::
|
|
12
|
+
ActionMailbox::
|
|
13
|
+
Turbo::
|
|
14
|
+
ActiveRecord::
|
|
15
|
+
].freeze
|
|
16
|
+
|
|
17
|
+
# Conventional application base-class names.
|
|
18
|
+
BASE_JOB_NAMES = %w[ApplicationJob].freeze
|
|
19
|
+
|
|
20
|
+
def initialize(excluded: [])
|
|
21
|
+
@excluded_set = excluded.to_set
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def call
|
|
25
|
+
{
|
|
26
|
+
adapter: detect_adapter,
|
|
27
|
+
active_jobs: collect_active_jobs,
|
|
28
|
+
sidekiq_workers: collect_sidekiq_workers
|
|
29
|
+
}
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def detect_adapter
|
|
35
|
+
ActiveJob::Base.queue_adapter_name.to_s
|
|
36
|
+
rescue StandardError
|
|
37
|
+
'unknown'
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def collect_active_jobs
|
|
41
|
+
return [] unless defined?(ActiveJob::Base)
|
|
42
|
+
|
|
43
|
+
ActiveJob::Base
|
|
44
|
+
.descendants
|
|
45
|
+
.reject { |j| j.name.nil? || framework_class?(j.name) || base_job?(j.name) || @excluded_set.include?(j.name) }
|
|
46
|
+
.map { |j| serialize_active_job(j) }
|
|
47
|
+
rescue StandardError
|
|
48
|
+
[]
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def serialize_active_job(job)
|
|
52
|
+
{
|
|
53
|
+
name: job.name,
|
|
54
|
+
file_path: source_path_for(job),
|
|
55
|
+
queue: safe_queue_name(job),
|
|
56
|
+
priority: job.priority,
|
|
57
|
+
retry_on: extract_retry_config(job),
|
|
58
|
+
discard_on: extract_discard_config(job)
|
|
59
|
+
}
|
|
60
|
+
rescue StandardError
|
|
61
|
+
{ name: job.name, file_path: nil, queue: 'default', priority: nil, retry_on: [], discard_on: [] }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# queue_name may be a Proc/lambda when set via a block (queue_as { ... }).
|
|
65
|
+
# Calling .to_s on a Proc produces an unreadable string; return 'dynamic'.
|
|
66
|
+
def safe_queue_name(job)
|
|
67
|
+
name = job.queue_name
|
|
68
|
+
name.is_a?(String) ? name : 'dynamic'
|
|
69
|
+
rescue StandardError
|
|
70
|
+
'default'
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def extract_retry_config(job)
|
|
74
|
+
job.rescue_handlers
|
|
75
|
+
.map { |h| { error: h[0].to_s, handler: h[1].class.name } }
|
|
76
|
+
rescue StandardError
|
|
77
|
+
[]
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def extract_discard_config(job)
|
|
81
|
+
return [] unless job.respond_to?(:discard_on_handlers)
|
|
82
|
+
|
|
83
|
+
job.discard_on_handlers.map(&:to_s)
|
|
84
|
+
rescue StandardError
|
|
85
|
+
[]
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def collect_sidekiq_workers
|
|
89
|
+
return [] unless defined?(Sidekiq)
|
|
90
|
+
|
|
91
|
+
worker_module = defined?(Sidekiq::Job) ? Sidekiq::Job : Sidekiq::Worker
|
|
92
|
+
|
|
93
|
+
ObjectSpace.each_object(Class)
|
|
94
|
+
.select { |c| c < worker_module && !c.name.nil? }
|
|
95
|
+
.reject { |c| framework_class?(c.name) || base_job?(c.name) || @excluded_set.include?(c.name) }
|
|
96
|
+
.map { |c| serialize_sidekiq_worker(c) }
|
|
97
|
+
rescue StandardError
|
|
98
|
+
[]
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def serialize_sidekiq_worker(worker)
|
|
102
|
+
opts = worker.respond_to?(:sidekiq_options) ? worker.sidekiq_options : {}
|
|
103
|
+
{
|
|
104
|
+
name: worker.name,
|
|
105
|
+
file_path: source_path_for(worker),
|
|
106
|
+
queue: opts['queue'] || 'default',
|
|
107
|
+
retry: opts['retry'],
|
|
108
|
+
unique: opts['unique']
|
|
109
|
+
}
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def framework_class?(name)
|
|
113
|
+
FRAMEWORK_NAMESPACES.any? { |ns| name.start_with?(ns) }
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def base_job?(name)
|
|
117
|
+
BASE_JOB_NAMES.include?(name)
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|