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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +110 -0
- data/README.md +33 -6
- 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/assets/javascripts/rails_nexus/controllers.js +82 -5
- data/app/assets/stylesheets/rails_nexus/application.css +170 -60
- 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/backup_controller.rb +22 -19
- 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 +43 -43
- data/app/helpers/rails_nexus/application_helper.rb +5 -0
- 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/controllers/rails_nexus_controller.js +6 -2
- data/app/javascript/controllers/rails_nexus_sidebar_controller.js +71 -3
- data/app/javascript/rails_nexus/application.js +15 -3
- data/app/jobs/rails_nexus/backup_job.rb +18 -0
- data/app/models/rails_nexus/backup.rb +11 -11
- data/app/models/rails_nexus/backup_config.rb +38 -2
- data/app/models/rails_nexus/logged_exception.rb +27 -13
- data/app/services/rails_nexus/backup_service.rb +342 -120
- data/app/views/layouts/rails_nexus/_sidebar_navigation.html.erb +40 -10
- data/app/views/layouts/rails_nexus/application.html.erb +72 -14
- data/app/views/rails_nexus/backup/_form.html.erb +3 -0
- data/app/views/rails_nexus/backup/history.html.erb +17 -2
- data/app/views/rails_nexus/backup/index.html.erb +29 -72
- data/app/views/rails_nexus/logged_exceptions/_exceptions.html.erb +12 -44
- data/app/views/rails_nexus/logged_exceptions/index.html.erb +21 -99
- data/app/views/rails_nexus/stats/index.html.erb +1 -1
- data/db/seeds/backup_configs.rb +5 -4
- 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 +8 -2
- 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 -0
- data/lib/rails_nexus/database_adapter.rb +53 -0
- data/lib/rails_nexus/engine.rb +0 -13
- 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 +4 -4
- data/rails_nexus.gemspec +3 -2
- metadata +9 -16
- 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 :
|
|
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 :
|
|
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!(
|
|
19
|
+
def self.start!(config_name:, triggered_by: "system")
|
|
20
20
|
create!(
|
|
21
|
-
|
|
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(
|
|
51
|
-
records =
|
|
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
|
-
|
|
59
|
-
results =
|
|
60
|
-
latest =
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
303
|
-
|
|
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:
|
|
440
|
-
vsz_kb:
|
|
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
|
-
|
|
499
|
-
|
|
500
|
-
|
|
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://"}#{
|
|
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: #{
|
|
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(
|
|
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
|