rails_nexus 2.1.0 → 2.1.3
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/CHANGELOG.md +110 -0
- data/README.md +68 -13
- data/SECURITY.md +11 -0
- data/THIRD_PARTY_NOTICES.md +14 -0
- data/app/assets/config/rails_nexus_manifest.js +1 -1
- data/app/assets/javascripts/rails_nexus/application.js +55 -0
- data/app/controllers/rails_nexus/analytics_controller.rb +13 -26
- data/app/controllers/rails_nexus/application_controller.rb +6 -2
- data/app/controllers/rails_nexus/cron_jobs_controller.rb +1 -9
- data/app/controllers/rails_nexus/database_health_controller.rb +0 -10
- data/app/controllers/rails_nexus/logged_exceptions_controller.rb +0 -2
- data/app/controllers/rails_nexus/n1_patterns_controller.rb +0 -2
- data/app/controllers/rails_nexus/settings_controller.rb +1 -39
- data/app/controllers/rails_nexus/source_code_controller.rb +9 -13
- data/app/controllers/rails_nexus/stats_controller.rb +33 -159
- data/app/helpers/rails_nexus/logged_exceptions_helper.rb +4 -49
- data/app/helpers/rails_nexus/source_code_helper.rb +49 -10
- data/app/javascript/rails_nexus/application.js +15 -3
- data/app/models/rails_nexus/logged_exception.rb +27 -13
- data/app/views/layouts/rails_nexus/_sidebar_navigation.html.erb +0 -11
- data/app/views/layouts/rails_nexus/application.html.erb +2 -13
- data/app/views/rails_nexus/stats/index.html.erb +1 -102
- data/config/routes.rb +0 -11
- data/lib/generators/rails_nexus/install_generator.rb +1 -1
- data/lib/generators/rails_nexus/templates/INSTALL.md +1 -0
- data/lib/generators/rails_nexus/templates/migration.rb +0 -82
- data/lib/generators/rails_nexus/templates/rails_nexus.rb +3 -0
- data/lib/generators/rails_nexus/templates/views/layouts/rails_nexus/application.html.erb +1 -1
- data/lib/rails_nexus/configuration.rb +10 -45
- data/lib/rails_nexus/database_adapter.rb +53 -0
- data/lib/rails_nexus/engine.rb +6 -17
- data/lib/rails_nexus/logger.rb +1 -12
- data/lib/rails_nexus/notifications.rb +27 -20
- data/lib/rails_nexus/version.rb +1 -1
- data/lib/rails_nexus/webhook_client.rb +151 -0
- data/lib/rails_nexus.rb +37 -23
- data/lib/tasks/rails_nexus.rake +0 -80
- data/rails_nexus.gemspec +4 -5
- metadata +12 -41
- data/app/controllers/rails_nexus/backup_controller.rb +0 -120
- data/app/jobs/rails_nexus/backup_job.rb +0 -18
- data/app/models/rails_nexus/backup.rb +0 -107
- data/app/models/rails_nexus/backup_config.rb +0 -158
- data/app/services/rails_nexus/backup_runner.rb +0 -62
- data/app/services/rails_nexus/backup_service.rb +0 -682
- data/app/views/rails_nexus/backup/_form.html.erb +0 -334
- data/app/views/rails_nexus/backup/config_history.html.erb +0 -67
- data/app/views/rails_nexus/backup/edit.html.erb +0 -15
- data/app/views/rails_nexus/backup/history.html.erb +0 -75
- data/app/views/rails_nexus/backup/index.html.erb +0 -112
- data/app/views/rails_nexus/backup/new.html.erb +0 -15
- data/config/importmap.rb +0 -15
- data/db/seeds/backup_configs.rb +0 -140
- data/lib/generators/rails_nexus/backup_generator.rb +0 -70
- data/lib/generators/rails_nexus/templates/backup/backup_helper.sh +0 -152
- data/lib/generators/rails_nexus/templates/backup/config.rb +0 -27
- data/lib/generators/rails_nexus/templates/backup/models/daily_backup.rb +0 -24
- data/lib/generators/rails_nexus/templates/backup/models/full_backup.rb +0 -35
- data/lib/generators/rails_nexus/templates/backup/models/sync_backup.rb +0 -43
- data/lib/generators/rails_nexus/templates/backup/mysql-config/db_config.cnf +0 -15
- data/lib/generators/rails_nexus/templates/backup/schedule.rb +0 -28
|
@@ -2,8 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
module RailsNexus
|
|
4
4
|
class LoggedExceptionsController < ApplicationController
|
|
5
|
-
before_action :rails_nexus_require_auth!
|
|
6
|
-
|
|
7
5
|
RANSACK_FILTERS = %i[
|
|
8
6
|
action_name_cont assigned_to_cont controller_name_cont created_at_gteq created_at_lteq
|
|
9
7
|
exception_class_cont fingerprint_cont message_cont
|
|
@@ -2,8 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
module RailsNexus
|
|
4
4
|
class SettingsController < ApplicationController
|
|
5
|
-
before_action :verify_access
|
|
6
|
-
|
|
7
5
|
def index
|
|
8
6
|
@webhooks = RailsNexus.configuration.webhooks || []
|
|
9
7
|
@webhook_headers = RailsNexus.configuration.webhook_headers || {}
|
|
@@ -43,14 +41,6 @@ module RailsNexus
|
|
|
43
41
|
|
|
44
42
|
private
|
|
45
43
|
|
|
46
|
-
def verify_access
|
|
47
|
-
config = RailsNexus.configuration
|
|
48
|
-
return if config.auth_block.nil?
|
|
49
|
-
unless config.auth_block&.call(self)
|
|
50
|
-
render plain: "Forbidden", status: :forbidden
|
|
51
|
-
end
|
|
52
|
-
end
|
|
53
|
-
|
|
54
44
|
def build_test_payload
|
|
55
45
|
{
|
|
56
46
|
event: "webhook.test",
|
|
@@ -66,35 +56,7 @@ module RailsNexus
|
|
|
66
56
|
end
|
|
67
57
|
|
|
68
58
|
def deliver_test_webhook(url, payload)
|
|
69
|
-
|
|
70
|
-
require "uri"
|
|
71
|
-
require "json"
|
|
72
|
-
|
|
73
|
-
uri = URI(url)
|
|
74
|
-
http = Net::HTTP.new(uri.host, uri.port)
|
|
75
|
-
http.use_ssl = uri.scheme == "https"
|
|
76
|
-
http.open_timeout = 10
|
|
77
|
-
http.read_timeout = 10
|
|
78
|
-
|
|
79
|
-
request = Net::HTTP::Post.new(uri)
|
|
80
|
-
request["Content-Type"] = "application/json"
|
|
81
|
-
request.body = payload.to_json
|
|
82
|
-
|
|
83
|
-
start_time = Time.now
|
|
84
|
-
response = http.request(request)
|
|
85
|
-
duration = Time.now - start_time
|
|
86
|
-
|
|
87
|
-
{
|
|
88
|
-
success: response.is_a?(Net::HTTPSuccess),
|
|
89
|
-
status_code: response.code,
|
|
90
|
-
body: response.body&.truncate(500),
|
|
91
|
-
duration: duration.round(3)
|
|
92
|
-
}
|
|
93
|
-
rescue StandardError => e
|
|
94
|
-
{
|
|
95
|
-
success: false,
|
|
96
|
-
error: e.message
|
|
97
|
-
}
|
|
59
|
+
RailsNexus::WebhookClient.deliver(url: url, payload: payload, timeout: 10)
|
|
98
60
|
end
|
|
99
61
|
end
|
|
100
62
|
end
|
|
@@ -7,27 +7,23 @@ module RailsNexus
|
|
|
7
7
|
# GET /rails_nexus/source_code?file=...&line=...
|
|
8
8
|
def show
|
|
9
9
|
file_path = params[:file]
|
|
10
|
-
line_number = params[:line]
|
|
10
|
+
line_number = Integer(params[:line], exception: false)
|
|
11
11
|
|
|
12
|
-
if file_path.blank? || line_number
|
|
12
|
+
if file_path.blank? || !line_number&.positive?
|
|
13
13
|
render json: { error: "file and line parameters required" }, status: :bad_request
|
|
14
14
|
return
|
|
15
15
|
end
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
unless real_path.start_with?(Rails.root.to_s)
|
|
21
|
-
render json: { error: "Access denied" }, status: :forbidden
|
|
22
|
-
return
|
|
23
|
-
end
|
|
24
|
-
rescue Errno::ENOENT
|
|
25
|
-
render json: { error: "File not found" }, status: :not_found
|
|
17
|
+
resolved_path = resolve_source_path(file_path)
|
|
18
|
+
unless resolved_path
|
|
19
|
+
render json: { error: "File not found or access denied" }, status: :not_found
|
|
26
20
|
return
|
|
27
21
|
end
|
|
28
22
|
|
|
29
|
-
|
|
30
|
-
|
|
23
|
+
requested_context = Integer(params[:context], exception: false) || 5
|
|
24
|
+
context_lines = requested_context.clamp(0, 50)
|
|
25
|
+
source = read_source_snippet(resolved_path, line_number, context_lines: context_lines)
|
|
26
|
+
blame = git_blame_for_line(resolved_path, line_number)
|
|
31
27
|
|
|
32
28
|
if source
|
|
33
29
|
render json: { source: source, blame: blame }
|
|
@@ -2,8 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
module RailsNexus
|
|
4
4
|
class StatsController < ApplicationController
|
|
5
|
-
before_action :verify_access
|
|
6
|
-
|
|
7
5
|
def index
|
|
8
6
|
@system_stats = collect_system_stats
|
|
9
7
|
@ruby_stats = collect_ruby_stats
|
|
@@ -13,20 +11,11 @@ module RailsNexus
|
|
|
13
11
|
@exception_stats = collect_exception_stats
|
|
14
12
|
@platform_stats = collect_platform_stats
|
|
15
13
|
@database_stats = collect_database_stats
|
|
16
|
-
@backup_stats = collect_backup_stats
|
|
17
14
|
@storm_stats = collect_storm_stats if defined?(RailsNexus::StormProtection)
|
|
18
15
|
end
|
|
19
16
|
|
|
20
17
|
private
|
|
21
18
|
|
|
22
|
-
def verify_access
|
|
23
|
-
config = RailsNexus.configuration
|
|
24
|
-
return if config.auth_block.nil?
|
|
25
|
-
unless config.auth_block&.call(self)
|
|
26
|
-
render plain: "Forbidden", status: :forbidden
|
|
27
|
-
end
|
|
28
|
-
end
|
|
29
|
-
|
|
30
19
|
def collect_system_stats
|
|
31
20
|
stats = {}
|
|
32
21
|
|
|
@@ -83,7 +72,7 @@ module RailsNexus
|
|
|
83
72
|
process_stats[:thread_count] = "N/A"
|
|
84
73
|
end
|
|
85
74
|
begin
|
|
86
|
-
process_stats[:open_files] =
|
|
75
|
+
process_stats[:open_files] = Dir.children("/proc/#{pid}/fd").size
|
|
87
76
|
rescue StandardError
|
|
88
77
|
process_stats[:open_files] = 0
|
|
89
78
|
end
|
|
@@ -133,13 +122,14 @@ module RailsNexus
|
|
|
133
122
|
}
|
|
134
123
|
else
|
|
135
124
|
# Fallback: use `df` command
|
|
136
|
-
|
|
125
|
+
stdout, _stderr, status = Open3.capture3("df", "-h", "/")
|
|
126
|
+
df_output = status.success? ? stdout.lines.last&.split : nil
|
|
137
127
|
if df_output
|
|
138
128
|
{
|
|
139
129
|
total_gb: df_output[1],
|
|
140
130
|
used_gb: df_output[2],
|
|
141
131
|
available_gb: df_output[3],
|
|
142
|
-
usage_percent: df_output[4]
|
|
132
|
+
usage_percent: df_output[4].to_i
|
|
143
133
|
}
|
|
144
134
|
else
|
|
145
135
|
{ total_gb: "N/A", used_gb: "N/A", available_gb: "N/A", usage_percent: 0 }
|
|
@@ -201,11 +191,7 @@ module RailsNexus
|
|
|
201
191
|
stats[:pool_available] = pool.available? ? pool.connections.size : 0
|
|
202
192
|
|
|
203
193
|
# Database version
|
|
204
|
-
|
|
205
|
-
stats[:version] = ActiveRecord::Base.connection.select_value("SELECT version()")
|
|
206
|
-
rescue StandardError
|
|
207
|
-
stats[:version] = "N/A"
|
|
208
|
-
end
|
|
194
|
+
stats[:version] = RailsNexus::DatabaseAdapter.database_version
|
|
209
195
|
|
|
210
196
|
# Table stats (rails_nexus tables)
|
|
211
197
|
stats[:tables] = collect_table_stats
|
|
@@ -256,131 +242,6 @@ module RailsNexus
|
|
|
256
242
|
{ error: e.message }
|
|
257
243
|
end
|
|
258
244
|
|
|
259
|
-
def collect_backup_stats
|
|
260
|
-
stats = { detected: false, gem: nil, status: "unknown", last_backup: nil, details: {} }
|
|
261
|
-
|
|
262
|
-
# Check for backup gem
|
|
263
|
-
if defined?(Backup)
|
|
264
|
-
stats[:detected] = true
|
|
265
|
-
stats[:gem] = "backup"
|
|
266
|
-
stats[:version] = Backup::VERSION if Backup.const_defined?(:VERSION)
|
|
267
|
-
|
|
268
|
-
begin
|
|
269
|
-
# Check for backup configuration
|
|
270
|
-
config_path = Rails.root.join("config", "backup")
|
|
271
|
-
if config_path.exist?
|
|
272
|
-
stats[:details][:config_path] = config_path.to_s
|
|
273
|
-
stats[:status] = "configured"
|
|
274
|
-
end
|
|
275
|
-
|
|
276
|
-
# Check for backup logs
|
|
277
|
-
log_path = Rails.root.join("log", "backup")
|
|
278
|
-
if log_path.exist?
|
|
279
|
-
log_files = log_path.glob("*.log").sort_by(&:mtime).reverse
|
|
280
|
-
if log_files.any?
|
|
281
|
-
last_log = log_files.first
|
|
282
|
-
stats[:details][:log_file] = last_log.to_s
|
|
283
|
-
stats[:details][:log_size] = (last_log.size / 1024.0).round(1)
|
|
284
|
-
|
|
285
|
-
# Parse last backup time from log
|
|
286
|
-
log_content = last_log.read(1000) # Read first 1KB
|
|
287
|
-
if log_content.match(/started at (\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})/)
|
|
288
|
-
stats[:last_backup] = Time.parse($1)
|
|
289
|
-
end
|
|
290
|
-
end
|
|
291
|
-
end
|
|
292
|
-
|
|
293
|
-
# Check for backup models
|
|
294
|
-
models = Backup::Model.all rescue []
|
|
295
|
-
if models.any?
|
|
296
|
-
stats[:details][:models] = models.map do |m|
|
|
297
|
-
{
|
|
298
|
-
name: m.name,
|
|
299
|
-
schedule: m.schedule&.to_s,
|
|
300
|
-
triggers: m.triggers.map(&:name)
|
|
301
|
-
}
|
|
302
|
-
end
|
|
303
|
-
stats[:status] = "configured"
|
|
304
|
-
end
|
|
305
|
-
rescue StandardError => e
|
|
306
|
-
stats[:details][:error] = e.message
|
|
307
|
-
end
|
|
308
|
-
end
|
|
309
|
-
|
|
310
|
-
# Check for pg_dump (PostgreSQL)
|
|
311
|
-
if system("which pg_dump > /dev/null 2>&1")
|
|
312
|
-
stats[:detected] = true
|
|
313
|
-
stats[:gem] ||= "pg_dump"
|
|
314
|
-
stats[:details][:pg_dump] = `pg_dump --version 2>/dev/null`.strip
|
|
315
|
-
end
|
|
316
|
-
|
|
317
|
-
# Check for mysqldump (MySQL)
|
|
318
|
-
if system("which mysqldump > /dev/null 2>&1")
|
|
319
|
-
stats[:detected] = true
|
|
320
|
-
stats[:gem] ||= "mysqldump"
|
|
321
|
-
stats[:details][:mysqldump] = `mysqldump --version 2>/dev/null`.strip
|
|
322
|
-
end
|
|
323
|
-
|
|
324
|
-
# Check for AWS CLI (RDS backups)
|
|
325
|
-
if system("which aws > /dev/null 2>&1")
|
|
326
|
-
stats[:detected] = true
|
|
327
|
-
stats[:gem] ||= "aws-cli"
|
|
328
|
-
stats[:details][:aws_cli] = `aws --version 2>/dev/null`.strip
|
|
329
|
-
|
|
330
|
-
# Check for RDS snapshots if configured
|
|
331
|
-
begin
|
|
332
|
-
rds_client = Aws::RDS::Client.new
|
|
333
|
-
snapshots = rds_client.describe_db_snapshots(db_snapshot_identifier: "automated-*")
|
|
334
|
-
stats[:details][:rds_snapshots] = snapshots.db_snapshots.count
|
|
335
|
-
stats[:status] = "configured" if snapshots.db_snapshots.any?
|
|
336
|
-
rescue StandardError
|
|
337
|
-
# AWS not configured or no access
|
|
338
|
-
end
|
|
339
|
-
end
|
|
340
|
-
|
|
341
|
-
# Check for docker backup solutions
|
|
342
|
-
if File.exist?("/var/lib/docker")
|
|
343
|
-
stats[:detected] = true
|
|
344
|
-
stats[:gem] ||= "docker"
|
|
345
|
-
stats[:details][:docker] = `docker --version 2>/dev/null`.strip
|
|
346
|
-
end
|
|
347
|
-
|
|
348
|
-
# Check for crontab entries related to backups
|
|
349
|
-
begin
|
|
350
|
-
crontab = `crontab -l 2>/dev/null`
|
|
351
|
-
if crontab.include?("backup") || crontab.include?("dump")
|
|
352
|
-
stats[:details][:crontab_backup] = true
|
|
353
|
-
stats[:status] = "scheduled" if stats[:status] == "unknown"
|
|
354
|
-
end
|
|
355
|
-
rescue StandardError
|
|
356
|
-
# Cannot read crontab
|
|
357
|
-
end
|
|
358
|
-
|
|
359
|
-
# Check for backup-related environment variables
|
|
360
|
-
backup_env_vars = %w[BACKUP_DIR BACKUP_S3_BUCKET BACKUP_GCS_BUCKET DATABASE_BACKUP_URL]
|
|
361
|
-
found_env = backup_env_vars.select { |var| ENV[var] }
|
|
362
|
-
if found_env.any?
|
|
363
|
-
stats[:detected] = true
|
|
364
|
-
stats[:details][:env_vars] = found_env
|
|
365
|
-
end
|
|
366
|
-
|
|
367
|
-
# Determine overall status
|
|
368
|
-
if stats[:last_backup]
|
|
369
|
-
hours_since = (Time.now - stats[:last_backup]) / 3600
|
|
370
|
-
if hours_since < 24
|
|
371
|
-
stats[:status] = "healthy"
|
|
372
|
-
elsif hours_since < 168 # 7 days
|
|
373
|
-
stats[:status] = "warning"
|
|
374
|
-
else
|
|
375
|
-
stats[:status] = "stale"
|
|
376
|
-
end
|
|
377
|
-
elsif stats[:status] == "unknown"
|
|
378
|
-
stats[:status] = "not_configured"
|
|
379
|
-
end
|
|
380
|
-
|
|
381
|
-
stats
|
|
382
|
-
end
|
|
383
|
-
|
|
384
245
|
def collect_table_stats
|
|
385
246
|
tables = []
|
|
386
247
|
|
|
@@ -391,11 +252,13 @@ module RailsNexus
|
|
|
391
252
|
begin
|
|
392
253
|
next unless ActiveRecord::Base.connection.table_exists?(table_name)
|
|
393
254
|
|
|
394
|
-
|
|
255
|
+
quoted_table = ActiveRecord::Base.connection.quote_table_name(table_name)
|
|
256
|
+
count = ActiveRecord::Base.connection.select_value("SELECT COUNT(*) FROM #{quoted_table}").to_i
|
|
395
257
|
tables << {
|
|
396
258
|
name: table_name,
|
|
397
259
|
row_count: count,
|
|
398
|
-
size_mb:
|
|
260
|
+
size_mb: nil,
|
|
261
|
+
size_supported: false,
|
|
399
262
|
status: count > 0 ? "active" : "empty"
|
|
400
263
|
}
|
|
401
264
|
rescue StandardError
|
|
@@ -415,7 +278,7 @@ module RailsNexus
|
|
|
415
278
|
SQL
|
|
416
279
|
result.each do |row|
|
|
417
280
|
table = tables.find { |t| t[:name] == row["table_name"] }
|
|
418
|
-
table
|
|
281
|
+
table&.merge!(size_mb: row["size_mb"].to_f, size_supported: true)
|
|
419
282
|
end
|
|
420
283
|
elsif adapter.include?("postgresql")
|
|
421
284
|
result = ActiveRecord::Base.connection.execute(<<~SQL)
|
|
@@ -427,7 +290,7 @@ module RailsNexus
|
|
|
427
290
|
SQL
|
|
428
291
|
result.each do |row|
|
|
429
292
|
table = tables.find { |t| t[:name] == row["table_name"] }
|
|
430
|
-
table
|
|
293
|
+
table&.merge!(size_mb: (row["size_bytes"].to_f / 1024 / 1024).round(2), size_supported: true)
|
|
431
294
|
end
|
|
432
295
|
end
|
|
433
296
|
rescue StandardError
|
|
@@ -437,6 +300,20 @@ module RailsNexus
|
|
|
437
300
|
tables
|
|
438
301
|
end
|
|
439
302
|
|
|
303
|
+
def executable_version(name)
|
|
304
|
+
executable = ENV.fetch("PATH", "").split(File::PATH_SEPARATOR)
|
|
305
|
+
.map { |directory| File.join(directory, name) }
|
|
306
|
+
.find { |path| File.file?(path) && File.executable?(path) }
|
|
307
|
+
return nil unless executable
|
|
308
|
+
|
|
309
|
+
stdout, stderr, status = Open3.capture3(executable, "--version")
|
|
310
|
+
return nil unless status.success?
|
|
311
|
+
|
|
312
|
+
[stdout, stderr].find(&:present?)&.lines&.first&.strip
|
|
313
|
+
rescue Errno::ENOENT, Errno::EACCES
|
|
314
|
+
nil
|
|
315
|
+
end
|
|
316
|
+
|
|
440
317
|
def collect_platform_stats
|
|
441
318
|
{
|
|
442
319
|
breakdown: LoggedException.platform_stats,
|
|
@@ -476,17 +353,14 @@ module RailsNexus
|
|
|
476
353
|
end
|
|
477
354
|
|
|
478
355
|
def process_uptime
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
else
|
|
488
|
-
"N/A"
|
|
489
|
-
end
|
|
356
|
+
stdout, _stderr, status = Open3.capture3("ps", "-o", "etimes=", "-p", Process.pid.to_s)
|
|
357
|
+
return "N/A" unless status.success?
|
|
358
|
+
|
|
359
|
+
seconds = stdout.to_i
|
|
360
|
+
days = seconds / 86_400
|
|
361
|
+
hours = (seconds % 86_400) / 3600
|
|
362
|
+
minutes = (seconds % 3600) / 60
|
|
363
|
+
"#{days}d #{hours}h #{minutes}m"
|
|
490
364
|
rescue StandardError
|
|
491
365
|
"N/A"
|
|
492
366
|
end
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
module RailsNexus
|
|
4
4
|
module LoggedExceptionsHelper
|
|
5
|
+
include SourceCodeHelper
|
|
5
6
|
def pretty_exception_date(exception)
|
|
6
7
|
if RailsNexus.configuration.date_format
|
|
7
8
|
return exception.created_at.strftime(RailsNexus.configuration.date_format)
|
|
@@ -186,58 +187,12 @@ module RailsNexus
|
|
|
186
187
|
|
|
187
188
|
# Read source code snippet around a file:line
|
|
188
189
|
def read_source_snippet(file_path, line_number, context: 5)
|
|
189
|
-
|
|
190
|
-
return nil unless File.exist?(file_path)
|
|
191
|
-
|
|
192
|
-
lines = File.readlines(file_path)
|
|
193
|
-
start_line = [line_number - context, 0].max
|
|
194
|
-
end_line = [line_number + context, lines.length - 1].min
|
|
195
|
-
|
|
196
|
-
snippet_lines = (start_line..end_line).map do |i|
|
|
197
|
-
{
|
|
198
|
-
number: i + 1,
|
|
199
|
-
content: lines[i]&.rstrip,
|
|
200
|
-
highlighted: (i + 1) == line_number
|
|
201
|
-
}
|
|
202
|
-
end
|
|
203
|
-
|
|
204
|
-
{ lines: snippet_lines, start: start_line + 1, end: end_line + 1 }
|
|
205
|
-
rescue StandardError
|
|
206
|
-
nil
|
|
190
|
+
super(file_path, line_number, context_lines: context)
|
|
207
191
|
end
|
|
208
192
|
|
|
209
193
|
# Get git blame for a file:line range
|
|
210
|
-
def git_blame_for_line(file_path, line_number,
|
|
211
|
-
|
|
212
|
-
return nil unless File.exist?(file_path)
|
|
213
|
-
|
|
214
|
-
start_line = [line_number - context, 1].max
|
|
215
|
-
end_line = line_number + context
|
|
216
|
-
|
|
217
|
-
result = `git blame -L #{start_line},#{end_line} --porcelain #{file_path} 2>/dev/null`
|
|
218
|
-
return nil unless $?.success?
|
|
219
|
-
|
|
220
|
-
blame_data = {}
|
|
221
|
-
current_sha = nil
|
|
222
|
-
|
|
223
|
-
result.each_line do |line|
|
|
224
|
-
if line =~ /^([0-9a-f]{40})\s+(\d+)\s+(\d+)/
|
|
225
|
-
current_sha = $1
|
|
226
|
-
line_num = $2.to_i
|
|
227
|
-
blame_data[line_num] = { sha: current_sha&.slice(0, 7) } if line_num == line_number
|
|
228
|
-
elsif line =~ /^author\s+(.+)/
|
|
229
|
-
blame_data.each_value { |v| v[:author] = $1.strip if v[:sha] == current_sha&.slice(0, 7) && v[:author].nil? }
|
|
230
|
-
elsif line =~ /^author-time\s+(\d+)/
|
|
231
|
-
time = Time.at($1.to_i)
|
|
232
|
-
blame_data.each_value { |v| v[:date] = time.strftime("%Y-%m-%d") if v[:sha] == current_sha&.slice(0, 7) && v[:date].nil? }
|
|
233
|
-
elsif line =~ /^summary\s+(.+)/
|
|
234
|
-
blame_data.each_value { |v| v[:message] = $1.strip if v[:sha] == current_sha&.slice(0, 7) && v[:message].nil? }
|
|
235
|
-
end
|
|
236
|
-
end
|
|
237
|
-
|
|
238
|
-
blame_data[line_number]
|
|
239
|
-
rescue StandardError
|
|
240
|
-
nil
|
|
194
|
+
def git_blame_for_line(file_path, line_number, **_options)
|
|
195
|
+
super(file_path, line_number)
|
|
241
196
|
end
|
|
242
197
|
|
|
243
198
|
# Parse backtrace line into file path and line number
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "open3"
|
|
4
|
+
require "pathname"
|
|
5
|
+
|
|
3
6
|
module RailsNexus
|
|
4
7
|
module SourceCodeHelper
|
|
5
8
|
# Parse a backtrace line into components
|
|
@@ -20,16 +23,22 @@ module RailsNexus
|
|
|
20
23
|
# Read source code snippet around a specific line
|
|
21
24
|
# Returns { lines: Array, file: String, from_line: Integer, to_line: Integer, error_line: Integer }
|
|
22
25
|
def read_source_snippet(file_path, error_line, context_lines: 5)
|
|
23
|
-
|
|
26
|
+
resolved_path = resolve_source_path(file_path)
|
|
27
|
+
return nil unless resolved_path
|
|
28
|
+
|
|
29
|
+
error_line = Integer(error_line, exception: false)
|
|
30
|
+
return nil unless error_line&.positive?
|
|
31
|
+
|
|
32
|
+
context_lines = (Integer(context_lines, exception: false) || 5).clamp(0, 50)
|
|
24
33
|
|
|
25
|
-
total_lines = File.foreach(
|
|
34
|
+
total_lines = File.foreach(resolved_path).count
|
|
26
35
|
from_line = [error_line - context_lines, 1].max
|
|
27
36
|
to_line = [error_line + context_lines, total_lines].min
|
|
28
37
|
|
|
29
38
|
lines = []
|
|
30
39
|
current_line = 0
|
|
31
40
|
|
|
32
|
-
File.foreach(
|
|
41
|
+
File.foreach(resolved_path) do |raw_line|
|
|
33
42
|
current_line += 1
|
|
34
43
|
break if current_line > to_line
|
|
35
44
|
next if current_line < from_line
|
|
@@ -43,7 +52,7 @@ module RailsNexus
|
|
|
43
52
|
|
|
44
53
|
{
|
|
45
54
|
lines: lines,
|
|
46
|
-
file:
|
|
55
|
+
file: resolved_path,
|
|
47
56
|
from_line: from_line,
|
|
48
57
|
to_line: to_line,
|
|
49
58
|
error_line: error_line
|
|
@@ -53,13 +62,23 @@ module RailsNexus
|
|
|
53
62
|
# Get git blame for a specific line
|
|
54
63
|
# Returns { sha: String, author: String, date: String, message: String }
|
|
55
64
|
def git_blame_for_line(file_path, line_number)
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
return nil unless
|
|
65
|
+
resolved_path = resolve_source_path(file_path)
|
|
66
|
+
line_number = Integer(line_number, exception: false)
|
|
67
|
+
return nil unless resolved_path && line_number&.positive?
|
|
68
|
+
|
|
69
|
+
directory = File.dirname(resolved_path)
|
|
70
|
+
repository_root, _stderr, status = Open3.capture3("git", "-C", directory, "rev-parse", "--show-toplevel")
|
|
71
|
+
return nil unless status.success?
|
|
72
|
+
|
|
73
|
+
repository_root = Pathname.new(repository_root.strip).realpath
|
|
74
|
+
relative_path = Pathname.new(resolved_path).relative_path_from(repository_root).to_s
|
|
75
|
+
return nil if relative_path == ".." || relative_path.start_with?("../")
|
|
59
76
|
|
|
60
77
|
# Get blame for single line
|
|
61
|
-
blame_output
|
|
62
|
-
|
|
78
|
+
blame_output, _stderr, status = Open3.capture3(
|
|
79
|
+
"git", "-C", repository_root.to_s, "blame", "-L", "#{line_number},#{line_number}", "--porcelain", "--", relative_path
|
|
80
|
+
)
|
|
81
|
+
return nil unless status.success?
|
|
63
82
|
|
|
64
83
|
# Parse porcelain blame output
|
|
65
84
|
result = {}
|
|
@@ -80,6 +99,8 @@ module RailsNexus
|
|
|
80
99
|
end
|
|
81
100
|
|
|
82
101
|
result.presence
|
|
102
|
+
rescue ArgumentError, Errno::ENOENT, Errno::EACCES
|
|
103
|
+
nil
|
|
83
104
|
end
|
|
84
105
|
|
|
85
106
|
# Get source code with git blame for multiple backtrace lines
|
|
@@ -99,9 +120,27 @@ module RailsNexus
|
|
|
99
120
|
parsed: parsed,
|
|
100
121
|
source: source,
|
|
101
122
|
blame: blame,
|
|
102
|
-
is_app_code: parsed[:file]
|
|
123
|
+
is_app_code: resolve_source_path(parsed[:file]).present?
|
|
103
124
|
}
|
|
104
125
|
end
|
|
105
126
|
end
|
|
127
|
+
|
|
128
|
+
# Resolve a path and ensure its canonical location is a regular file inside Rails.root.
|
|
129
|
+
def resolve_source_path(file_path)
|
|
130
|
+
return nil if file_path.blank?
|
|
131
|
+
|
|
132
|
+
root = Pathname.new(Rails.root).realpath
|
|
133
|
+
candidate = Pathname.new(file_path.to_s)
|
|
134
|
+
candidate = root.join(candidate) unless candidate.absolute?
|
|
135
|
+
resolved = candidate.realpath
|
|
136
|
+
relative = resolved.relative_path_from(root).to_s
|
|
137
|
+
|
|
138
|
+
return nil if relative == ".." || relative.start_with?("../")
|
|
139
|
+
return nil unless resolved.file?
|
|
140
|
+
|
|
141
|
+
resolved.to_s
|
|
142
|
+
rescue ArgumentError, Errno::ENOENT, Errno::EACCES
|
|
143
|
+
nil
|
|
144
|
+
end
|
|
106
145
|
end
|
|
107
146
|
end
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import "@hotwired/turbo-rails"
|
|
2
1
|
import { Application } from "@hotwired/stimulus"
|
|
3
2
|
|
|
4
3
|
import RailsNexusController from "rails_nexus/controllers/rails_nexus"
|
|
@@ -6,8 +5,19 @@ import RailsNexusDetailController from "rails_nexus/controllers/rails_nexus_deta
|
|
|
6
5
|
import RailsNexusSidebarController from "rails_nexus/controllers/rails_nexus_sidebar"
|
|
7
6
|
import RailsNexusThemeController from "rails_nexus/controllers/rails_nexus_theme"
|
|
8
7
|
|
|
9
|
-
|
|
10
|
-
|
|
8
|
+
async function ensureTurbo() {
|
|
9
|
+
if (window.Turbo) return
|
|
10
|
+
|
|
11
|
+
const module = await import("@hotwired/turbo-rails")
|
|
12
|
+
window.Turbo = module.Turbo
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Reuse an explicitly exposed host Stimulus application when possible. If the
|
|
16
|
+
// host keeps it module-local, create one namespaced engine application only.
|
|
17
|
+
const application = window.Stimulus?.register
|
|
18
|
+
? window.Stimulus
|
|
19
|
+
: (window.RailsNexusStimulus ||= Application.start())
|
|
20
|
+
|
|
11
21
|
application.debug = false
|
|
12
22
|
|
|
13
23
|
// Register engine controllers
|
|
@@ -15,3 +25,5 @@ application.register("rails_nexus", RailsNexusController)
|
|
|
15
25
|
application.register("rails_nexus-theme", RailsNexusThemeController)
|
|
16
26
|
application.register("rails_nexus-sidebar", RailsNexusSidebarController)
|
|
17
27
|
application.register("rails_nexus-detail", RailsNexusDetailController)
|
|
28
|
+
|
|
29
|
+
ensureTurbo()
|