rails_nexus 2.0.6 → 2.1.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.
Files changed (56) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +110 -0
  3. data/README.md +33 -6
  4. data/SECURITY.md +11 -0
  5. data/THIRD_PARTY_NOTICES.md +14 -0
  6. data/app/assets/config/rails_nexus_manifest.js +1 -1
  7. data/app/assets/javascripts/rails_nexus/application.js +55 -0
  8. data/app/assets/javascripts/rails_nexus/controllers.js +82 -5
  9. data/app/assets/stylesheets/rails_nexus/application.css +170 -60
  10. data/app/controllers/rails_nexus/analytics_controller.rb +13 -26
  11. data/app/controllers/rails_nexus/application_controller.rb +6 -2
  12. data/app/controllers/rails_nexus/backup_controller.rb +22 -19
  13. data/app/controllers/rails_nexus/cron_jobs_controller.rb +1 -9
  14. data/app/controllers/rails_nexus/database_health_controller.rb +0 -10
  15. data/app/controllers/rails_nexus/logged_exceptions_controller.rb +0 -2
  16. data/app/controllers/rails_nexus/n1_patterns_controller.rb +0 -2
  17. data/app/controllers/rails_nexus/settings_controller.rb +1 -39
  18. data/app/controllers/rails_nexus/source_code_controller.rb +9 -13
  19. data/app/controllers/rails_nexus/stats_controller.rb +43 -43
  20. data/app/helpers/rails_nexus/application_helper.rb +5 -0
  21. data/app/helpers/rails_nexus/logged_exceptions_helper.rb +4 -49
  22. data/app/helpers/rails_nexus/source_code_helper.rb +49 -10
  23. data/app/javascript/controllers/rails_nexus_controller.js +6 -2
  24. data/app/javascript/controllers/rails_nexus_sidebar_controller.js +71 -3
  25. data/app/javascript/rails_nexus/application.js +15 -3
  26. data/app/jobs/rails_nexus/backup_job.rb +18 -0
  27. data/app/models/rails_nexus/backup.rb +11 -11
  28. data/app/models/rails_nexus/backup_config.rb +38 -2
  29. data/app/models/rails_nexus/logged_exception.rb +27 -13
  30. data/app/services/rails_nexus/backup_service.rb +342 -120
  31. data/app/views/layouts/rails_nexus/_sidebar_navigation.html.erb +40 -10
  32. data/app/views/layouts/rails_nexus/application.html.erb +72 -14
  33. data/app/views/rails_nexus/backup/_form.html.erb +3 -0
  34. data/app/views/rails_nexus/backup/history.html.erb +17 -2
  35. data/app/views/rails_nexus/backup/index.html.erb +29 -72
  36. data/app/views/rails_nexus/logged_exceptions/_exceptions.html.erb +12 -44
  37. data/app/views/rails_nexus/logged_exceptions/index.html.erb +21 -99
  38. data/app/views/rails_nexus/stats/index.html.erb +1 -1
  39. data/db/seeds/backup_configs.rb +5 -4
  40. data/lib/generators/rails_nexus/install_generator.rb +1 -1
  41. data/lib/generators/rails_nexus/templates/INSTALL.md +1 -0
  42. data/lib/generators/rails_nexus/templates/migration.rb +8 -2
  43. data/lib/generators/rails_nexus/templates/rails_nexus.rb +3 -0
  44. data/lib/generators/rails_nexus/templates/views/layouts/rails_nexus/application.html.erb +1 -1
  45. data/lib/rails_nexus/configuration.rb +10 -0
  46. data/lib/rails_nexus/database_adapter.rb +53 -0
  47. data/lib/rails_nexus/engine.rb +0 -13
  48. data/lib/rails_nexus/logger.rb +1 -12
  49. data/lib/rails_nexus/notifications.rb +27 -20
  50. data/lib/rails_nexus/version.rb +1 -1
  51. data/lib/rails_nexus/webhook_client.rb +151 -0
  52. data/lib/rails_nexus.rb +37 -23
  53. data/lib/tasks/rails_nexus.rake +4 -4
  54. data/rails_nexus.gemspec +3 -2
  55. metadata +9 -16
  56. data/config/importmap.rb +0 -15
@@ -4,7 +4,7 @@ module RailsNexus
4
4
  class Backup < BaseRecord
5
5
  self.table_name = "rails_nexus_backups"
6
6
 
7
- validates :model_name, presence: true
7
+ validates :config_name, presence: true
8
8
  validates :status, presence: true, inclusion: { in: %w[running success failed] }
9
9
  validates :started_at, presence: true
10
10
 
@@ -12,13 +12,13 @@ module RailsNexus
12
12
  scope :successful, -> { where(status: "success") }
13
13
  scope :failed, -> { where(status: "failed") }
14
14
  scope :running, -> { where(status: "running") }
15
- scope :by_model, ->(name) { where(model_name: name) }
15
+ scope :by_config, ->(name) { where(config_name: name) }
16
16
  scope :latest_first, -> { order(started_at: :desc) }
17
17
 
18
18
  # Start tracking a backup run
19
- def self.start!(model_name:, triggered_by: "system")
19
+ def self.start!(config_name:, triggered_by: "system")
20
20
  create!(
21
- model_name: model_name,
21
+ config_name: config_name,
22
22
  status: "running",
23
23
  triggered_by: triggered_by,
24
24
  started_at: Time.current
@@ -47,25 +47,25 @@ module RailsNexus
47
47
  end
48
48
 
49
49
  # Get success rate for a model over the last N days
50
- def self.success_rate(model_name:, days: 7)
51
- records = by_model(model_name).where("started_at >= ?", days.days.ago).where.not(status: "running")
50
+ def self.success_rate(config_name:, days: 7)
51
+ records = by_config(config_name).where("started_at >= ?", days.days.ago).where.not(status: "running")
52
52
  return 0 if records.empty?
53
53
  (records.successful.count.to_f / records.count * 100).round(1)
54
54
  end
55
55
 
56
56
  # Get backup health summary
57
57
  def self.health_summary(alert_threshold_hours: 24)
58
- models = RailsNexus.configuration.backup_models || []
59
- results = models.map do |model_name|
60
- latest = by_model(model_name).successful.latest_first.first
58
+ configs = RailsNexus::BackupConfig.pluck(:name)
59
+ results = configs.map do |config_name|
60
+ latest = by_config(config_name).successful.latest_first.first
61
61
  age_hours = latest ? ((Time.current - latest.started_at) / 3600).round(1) : nil
62
62
 
63
63
  {
64
- model_name: model_name,
64
+ config_name: config_name,
65
65
  last_backup: latest&.started_at,
66
66
  age_hours: age_hours,
67
67
  status: age_hours.nil? ? "unknown" : (age_hours < alert_threshold_hours ? "healthy" : "stale"),
68
- success_rate: success_rate(model_name: model_name),
68
+ success_rate: success_rate(config_name: config_name),
69
69
  last_file_size: latest&.file_size
70
70
  }
71
71
  end
@@ -13,6 +13,7 @@ module RailsNexus
13
13
  attribute :enabled, :boolean, default: true
14
14
  attribute :compress, :boolean, default: true
15
15
  attribute :encrypted, :boolean, default: false
16
+ attribute :encrypt_base64, :boolean, default: false
16
17
  attribute :rsync_enabled, :boolean, default: false
17
18
  attribute :rsync_mirror, :boolean, default: false
18
19
  attribute :notify_on_success, :boolean, default: true
@@ -25,6 +26,10 @@ module RailsNexus
25
26
  attribute :split_chunks, :boolean, default: false
26
27
 
27
28
  validates :name, presence: true, uniqueness: true
29
+ validates :name, format: {
30
+ with: /\A[a-zA-Z0-9_$][a-zA-Z0-9_$.-]*\z/,
31
+ message: "may contain only letters, numbers, dots, underscores, dollar signs, and hyphens"
32
+ }
28
33
  validates :database_name, presence: true, if: -> { adapter != "redis" }
29
34
  validates :adapter, presence: true, inclusion: { in: ADAPTERS }
30
35
  validates :storage_path, presence: true
@@ -37,6 +42,11 @@ module RailsNexus
37
42
  validates :s3_secret_key, presence: true, if: :s3_enabled?
38
43
  validates :gpg_password, presence: true, if: :gpg_enabled?
39
44
  validates :email_to, presence: true, if: :email_notify?
45
+ validates :port, :rsync_port,
46
+ numericality: { only_integer: true, greater_than: 0, less_than_or_equal_to: 65_535 },
47
+ allow_nil: true
48
+ validate :database_identifier_is_safe
49
+ validate :remote_sync_values_are_safe
40
50
 
41
51
  scope :enabled, -> { where(enabled: true) }
42
52
  scope :disabled, -> { where(enabled: false) }
@@ -113,11 +123,15 @@ module RailsNexus
113
123
 
114
124
  # ─── Stats ─────────────────────────────────────────────────────
115
125
  def recent_backups(limit: 10)
116
- RailsNexus::Backup.where(model_name: name).order(started_at: :desc).limit(limit)
126
+ RailsNexus::Backup.where(config_name: name).order(started_at: :desc).limit(limit)
127
+ end
128
+
129
+ def last_failure
130
+ RailsNexus::Backup.where(config_name: name, status: "failed").order(started_at: :desc).first
117
131
  end
118
132
 
119
133
  def stats
120
- backups = RailsNexus::Backup.where(model_name: name)
134
+ backups = RailsNexus::Backup.where(config_name: name)
121
135
  recent = backups.where("started_at >= ?", 7.days.ago)
122
136
  {
123
137
  total: backups.count,
@@ -134,6 +148,28 @@ module RailsNexus
134
148
 
135
149
  private
136
150
 
151
+ def database_identifier_is_safe
152
+ return if database_name.blank? || sqlite?
153
+
154
+ unless database_name.match?(/\A[a-zA-Z0-9_$][a-zA-Z0-9_$.-]*\z/) && !database_name.include?("..")
155
+ errors.add(:database_name, "contains unsupported characters")
156
+ end
157
+ end
158
+
159
+ def remote_sync_values_are_safe
160
+ return unless rsync_enabled?
161
+
162
+ unless rsync_host.to_s.match?(/\A[a-zA-Z0-9](?:[a-zA-Z0-9.-]*[a-zA-Z0-9])?\z/)
163
+ errors.add(:rsync_host, "is not a valid hostname")
164
+ end
165
+ unless rsync_user.to_s.match?(/\A[a-zA-Z0-9_][a-zA-Z0-9_.-]*\z/)
166
+ errors.add(:rsync_user, "contains unsupported characters")
167
+ end
168
+ unless rsync_path.to_s.start_with?("/") && !rsync_path.to_s.match?(/[\0\r\n]/)
169
+ errors.add(:rsync_path, "must be an absolute remote path")
170
+ end
171
+ end
172
+
137
173
  def dump_extension
138
174
  parts = ["sql"]
139
175
  parts << "gz" if compress? && !bzip2_compress?
@@ -24,14 +24,14 @@ module RailsNexus
24
24
  scope :unassigned, -> { where(assigned_to: [nil, ""]) }
25
25
  scope :active, -> { not_muted.not_snoozed }
26
26
 
27
- def self.ransackable_attributes(auth_object = nil)
27
+ def self.ransackable_attributes(_auth_object = nil)
28
28
  %w[action_name backtrace cause_chain controller_name created_at device_type environment
29
29
  assigned_to comments_count exception_class fingerprint id instance_variables local_variables
30
30
  message muted occurrence_count platform platform_version priority remote_ip request
31
31
  snoozed_until system_health updated_at user_agent user_id user_info]
32
32
  end
33
33
 
34
- def self.ransackable_associations(auth_object = nil)
34
+ def self.ransackable_associations(_auth_object = nil)
35
35
  []
36
36
  end
37
37
 
@@ -299,8 +299,8 @@ module RailsNexus
299
299
 
300
300
  # Look for ActiveRecord collection iteration patterns:
301
301
  # e.g., .each, .map, .find_each near ActiveRecord calls
302
- has_iteration = app_lines.any? { |l| l.match?(/\.each|\.map|\.select|\.reject|\.flat_map|\.find_each/) }
303
- has_ar_call = app_lines.any? { |l| l.match?(/ActiveRecord|_callback|association|belongs_to|has_many|load_target|reload/) }
302
+ app_lines.any? { |l| l.match?(/\.each|\.map|\.select|\.reject|\.flat_map|\.find_each/) }
303
+ app_lines.any? { |l| l.match?(/ActiveRecord|_callback|association|belongs_to|has_many|load_target|reload/) }
304
304
 
305
305
  # Also detect: repeated similar backtrace across multiple exceptions of same class
306
306
  fingerprint = backtrace_fingerprint(app_lines)
@@ -435,9 +435,11 @@ module RailsNexus
435
435
  end
436
436
 
437
437
  begin
438
+ stdout, _stderr, status = Open3.capture3("ps", "-o", "rss=,vsz=", "-p", Process.pid.to_s)
439
+ memory = status.success? ? stdout.split.map(&:to_i) : []
438
440
  health[:memory] = {
439
- rss_kb: `ps -o rss= -p #{Process.pid} 2>/dev/null`.strip.to_i,
440
- vsz_kb: `ps -o vsz= -p #{Process.pid} 2>/dev/null`.strip.to_i
441
+ rss_kb: memory[0] || 0,
442
+ vsz_kb: memory[1] || 0
441
443
  }
442
444
  rescue StandardError
443
445
  health[:memory] = { error: "unable to capture" }
@@ -495,17 +497,28 @@ module RailsNexus
495
497
  if request.is_a?(String)
496
498
  write_attribute :request, request
497
499
  elsif request.respond_to?(:env) && request.env.is_a?(Hash)
498
- max = request.env.keys.max { |a, b| a.length <=> b.length }
499
- env = request.env.keys.sort.inject [] do |memo, key|
500
- memo << "* %-*s: %s" % [max.length, key, request.env[key].to_s.strip]
500
+ filtered_env = RailsNexus.filter_sensitive_data(request.env)
501
+ keys = filtered_env.keys.map(&:to_s).sort
502
+ max_length = keys.map(&:length).max || 0
503
+ env = keys.each_with_object([]) do |key, memo|
504
+ value = filtered_env[key] || filtered_env[key.to_sym]
505
+ memo << ("* %-*s: %s" % [max_length, key, value.to_s.strip])
501
506
  end
502
507
  write_attribute(:environment, (env << "* Process: #{$$}" << "* Server : #{self.class.host_name}").join("\n"))
503
508
 
504
509
  method_str = request.respond_to?(:get?) && request.get? ? "" : " #{request.respond_to?(:method) ? request.method.to_s.upcase : "GET"}"
510
+ parameters = request.respond_to?(:parameters) ? RailsNexus.filter_sensitive_data(request.parameters) : {}
511
+ request_path = if request.respond_to?(:path)
512
+ request.path
513
+ elsif request.respond_to?(:fullpath)
514
+ request.fullpath.to_s.split("?", 2).first
515
+ else
516
+ "/"
517
+ end
505
518
  write_attribute(:request, [
506
- "* URL:#{method_str} #{request.respond_to?(:protocol) ? request.protocol : "http://"}#{request.respond_to?(:env) ? request.env["HTTP_HOST"] : "localhost"}#{request.respond_to?(:fullpath) ? request.fullpath : "/"}",
519
+ "* URL:#{method_str} #{request.respond_to?(:protocol) ? request.protocol : "http://"}#{filtered_env["HTTP_HOST"] || "localhost"}#{request_path}",
507
520
  "* Format: #{request.respond_to?(:format) ? request.format.to_s : "html"}",
508
- "* Parameters: #{request.respond_to?(:parameters) ? request.parameters.inspect : "{}"}",
521
+ "* Parameters: #{parameters.inspect}",
509
522
  "* Rails Root: #{rails_root}"
510
523
  ].join("\n"))
511
524
  else
@@ -545,10 +558,11 @@ module RailsNexus
545
558
 
546
559
  # Build hourly time series of error counts
547
560
  def self.build_time_series(days: 30)
561
+ days = (Integer(days, exception: false) || 30).clamp(1, 90)
548
562
  start_time = days.days.ago.beginning_of_hour
563
+ bucket = RailsNexus::DatabaseAdapter.time_bucket_expression
549
564
  raw = where("created_at >= ?", start_time)
550
- .group(Arel.sql("DATE_FORMAT(created_at, '%Y-%m-%d %H:00')"))
551
- .order(Arel.sql("DATE_FORMAT(created_at, '%Y-%m-%d %H:00')"))
565
+ .group(Arel.sql(bucket))
552
566
  .count
553
567
 
554
568
  # Fill gaps with zeros