rails_nexus 2.1.2 → 2.1.4
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 +9 -0
- data/README.md +41 -10
- data/app/controllers/rails_nexus/stats_controller.rb +0 -126
- data/app/views/layouts/rails_nexus/_sidebar_navigation.html.erb +0 -11
- data/app/views/layouts/rails_nexus/application.html.erb +1 -12
- data/app/views/rails_nexus/stats/index.html.erb +0 -101
- data/config/routes.rb +0 -11
- data/lib/generators/rails_nexus/templates/migration.rb +0 -82
- data/lib/rails_nexus/configuration.rb +0 -45
- data/lib/rails_nexus/engine.rb +6 -4
- data/lib/rails_nexus/version.rb +1 -1
- data/lib/tasks/rails_nexus.rake +0 -80
- data/rails_nexus.gemspec +1 -3
- metadata +6 -27
- data/app/controllers/rails_nexus/backup_controller.rb +0 -119
- 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 -189
- data/app/services/rails_nexus/backup_runner.rb +0 -62
- data/app/services/rails_nexus/backup_service.rb +0 -817
- 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/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
data/lib/tasks/rails_nexus.rake
CHANGED
|
@@ -228,84 +228,4 @@ namespace :rails_nexus do
|
|
|
228
228
|
puts "✓ Deleted #{count} webhook delivery records older than #{days} days"
|
|
229
229
|
end
|
|
230
230
|
|
|
231
|
-
# ─── Backup Tasks ──────────────────────────────────────────────
|
|
232
|
-
|
|
233
|
-
desc "Run a specific backup by name: rails_nexus:backup[daily_backup]"
|
|
234
|
-
task :backup, [:name] => :environment do |_t, args|
|
|
235
|
-
name = args[:name]
|
|
236
|
-
if name.blank?
|
|
237
|
-
puts "Usage: rake rails_nexus:backup[config_name]"
|
|
238
|
-
puts "Available configs:"
|
|
239
|
-
RailsNexus::BackupConfig.enabled.pluck(:name).each { |n| puts " - #{n}" }
|
|
240
|
-
next
|
|
241
|
-
end
|
|
242
|
-
|
|
243
|
-
result = RailsNexus::BackupRunner.run(name)
|
|
244
|
-
if result[:success]
|
|
245
|
-
puts "✓ Backup "#{name}" completed successfully (#{result[:record].duration_human})"
|
|
246
|
-
else
|
|
247
|
-
puts "✗ Backup "#{name}" failed: #{result[:error]}"
|
|
248
|
-
exit 1
|
|
249
|
-
end
|
|
250
|
-
end
|
|
251
|
-
|
|
252
|
-
desc "Run all enabled backup configs"
|
|
253
|
-
task backup_all: :environment do
|
|
254
|
-
results = RailsNexus::BackupRunner.run_all
|
|
255
|
-
results.each do |r|
|
|
256
|
-
if r[:success]
|
|
257
|
-
puts "✓ #{r[:record].config_name} completed"
|
|
258
|
-
else
|
|
259
|
-
puts "✗ #{r[:record]&.config_name || '?'} failed: #{r[:error]}"
|
|
260
|
-
end
|
|
261
|
-
end
|
|
262
|
-
end
|
|
263
|
-
|
|
264
|
-
desc "Run backups matching current cron schedule"
|
|
265
|
-
task backup_scheduled: :environment do
|
|
266
|
-
results = RailsNexus::BackupRunner.run_scheduled
|
|
267
|
-
if results.empty?
|
|
268
|
-
puts "No backups scheduled for this time"
|
|
269
|
-
else
|
|
270
|
-
results.each do |r|
|
|
271
|
-
if r[:success]
|
|
272
|
-
puts "✓ #{r[:record].config_name} completed"
|
|
273
|
-
else
|
|
274
|
-
puts "✗ #{r[:record]&.config_name || '?'} failed: #{r[:error]}"
|
|
275
|
-
end
|
|
276
|
-
end
|
|
277
|
-
end
|
|
278
|
-
end
|
|
279
|
-
|
|
280
|
-
desc "Clean up old backup records and files (default: 30 days)"
|
|
281
|
-
task :backup_cleanup, [:days] => :environment do |_t, args|
|
|
282
|
-
days = (args[:days] || 30).to_i
|
|
283
|
-
count = RailsNexus::Backup.where("started_at < ?", days.days.ago).delete_all
|
|
284
|
-
puts "✓ Deleted #{count} backup records older than #{days} days"
|
|
285
|
-
end
|
|
286
|
-
|
|
287
|
-
desc "List all backup configs and their status"
|
|
288
|
-
task backup_list: :environment do
|
|
289
|
-
configs = RailsNexus::BackupConfig.order(:name)
|
|
290
|
-
if configs.empty?
|
|
291
|
-
puts "No backup configs found"
|
|
292
|
-
else
|
|
293
|
-
configs.each do |c|
|
|
294
|
-
status = c.enabled? ? "✓" : "✗"
|
|
295
|
-
last = c.recent_backups(limit: 1).first
|
|
296
|
-
last_str = last ? "#{last.status} #{last.started_at&.strftime('%b %d %H:%M')}" : "never"
|
|
297
|
-
puts "#{status} #{c.name.ljust(20)} #{c.adapter.ljust(12)} #{c.database_name.ljust(20)} last: #{last_str}"
|
|
298
|
-
end
|
|
299
|
-
end
|
|
300
|
-
end
|
|
301
|
-
|
|
302
|
-
desc "Seed backup configs from ~/Backup gem models"
|
|
303
|
-
task seed_backups: :environment do
|
|
304
|
-
seed_file = File.join(RailsNexus::Engine.root, "db", "seeds", "backup_configs.rb")
|
|
305
|
-
if File.exist?(seed_file)
|
|
306
|
-
load seed_file
|
|
307
|
-
else
|
|
308
|
-
puts "Seed file not found: #{seed_file}"
|
|
309
|
-
end
|
|
310
|
-
end
|
|
311
231
|
end
|
data/rails_nexus.gemspec
CHANGED
|
@@ -7,7 +7,7 @@ Gem::Specification.new do |spec|
|
|
|
7
7
|
spec.email = ["tamiruhailu@gmail.com"]
|
|
8
8
|
spec.homepage = "https://github.com/tamiru/rails_nexus"
|
|
9
9
|
spec.summary = "The extensible control plane for Rails applications"
|
|
10
|
-
spec.description = "RailsNexus is an extensible operations and administration console for Rails applications. It provides error monitoring,
|
|
10
|
+
spec.description = "RailsNexus is an extensible operations and administration console for Rails applications. It provides error monitoring, cron job tracking, server statistics, database health, real-time analytics, workflow management, source code viewing, N+1 query detection, and storm protection. Built with Hotwire, Stimulus, and Tailwind-compatible CSS."
|
|
11
11
|
spec.metadata["rubygems_mfa_required"] = "true"
|
|
12
12
|
spec.license = "MIT"
|
|
13
13
|
|
|
@@ -32,6 +32,4 @@ Gem::Specification.new do |spec|
|
|
|
32
32
|
|
|
33
33
|
spec.add_dependency "ransack", "~> 4.0"
|
|
34
34
|
|
|
35
|
-
# Note: Backup management requires 'backup' (~> 4.4) and 'whenever' (~> 1.1)
|
|
36
|
-
# gems to be added to your host application's Gemfile.
|
|
37
35
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: rails_nexus
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 2.1.
|
|
4
|
+
version: 2.1.4
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Tamiru Hailu
|
|
@@ -86,10 +86,10 @@ dependencies:
|
|
|
86
86
|
- !ruby/object:Gem::Version
|
|
87
87
|
version: '4.0'
|
|
88
88
|
description: RailsNexus is an extensible operations and administration console for
|
|
89
|
-
Rails applications. It provides error monitoring,
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
89
|
+
Rails applications. It provides error monitoring, cron job tracking, server statistics,
|
|
90
|
+
database health, real-time analytics, workflow management, source code viewing,
|
|
91
|
+
N+1 query detection, and storm protection. Built with Hotwire, Stimulus, and Tailwind-compatible
|
|
92
|
+
CSS.
|
|
93
93
|
email:
|
|
94
94
|
- tamiruhailu@gmail.com
|
|
95
95
|
executables: []
|
|
@@ -108,7 +108,6 @@ files:
|
|
|
108
108
|
- app/assets/stylesheets/rails_nexus/application.css
|
|
109
109
|
- app/controllers/rails_nexus/analytics_controller.rb
|
|
110
110
|
- app/controllers/rails_nexus/application_controller.rb
|
|
111
|
-
- app/controllers/rails_nexus/backup_controller.rb
|
|
112
111
|
- app/controllers/rails_nexus/cron_jobs_controller.rb
|
|
113
112
|
- app/controllers/rails_nexus/database_health_controller.rb
|
|
114
113
|
- app/controllers/rails_nexus/logged_exceptions_controller.rb
|
|
@@ -126,28 +125,17 @@ files:
|
|
|
126
125
|
- app/javascript/controllers/rails_nexus_theme_controller.js
|
|
127
126
|
- app/javascript/rails_nexus/application.js
|
|
128
127
|
- app/jobs/rails_nexus/application_job.rb
|
|
129
|
-
- app/jobs/rails_nexus/backup_job.rb
|
|
130
128
|
- app/mailers/rails_nexus/application_mailer.rb
|
|
131
129
|
- app/models/rails_nexus/application_record.rb
|
|
132
|
-
- app/models/rails_nexus/backup.rb
|
|
133
|
-
- app/models/rails_nexus/backup_config.rb
|
|
134
130
|
- app/models/rails_nexus/base_record.rb
|
|
135
131
|
- app/models/rails_nexus/comment.rb
|
|
136
132
|
- app/models/rails_nexus/cron_job.rb
|
|
137
133
|
- app/models/rails_nexus/logged_exception.rb
|
|
138
134
|
- app/models/rails_nexus/logged_exception_n1.rb
|
|
139
135
|
- app/models/rails_nexus/webhook_delivery.rb
|
|
140
|
-
- app/services/rails_nexus/backup_runner.rb
|
|
141
|
-
- app/services/rails_nexus/backup_service.rb
|
|
142
136
|
- app/views/layouts/rails_nexus/_sidebar_navigation.html.erb
|
|
143
137
|
- app/views/layouts/rails_nexus/application.html.erb
|
|
144
138
|
- app/views/rails_nexus/analytics/index.html.erb
|
|
145
|
-
- app/views/rails_nexus/backup/_form.html.erb
|
|
146
|
-
- app/views/rails_nexus/backup/config_history.html.erb
|
|
147
|
-
- app/views/rails_nexus/backup/edit.html.erb
|
|
148
|
-
- app/views/rails_nexus/backup/history.html.erb
|
|
149
|
-
- app/views/rails_nexus/backup/index.html.erb
|
|
150
|
-
- app/views/rails_nexus/backup/new.html.erb
|
|
151
139
|
- app/views/rails_nexus/cron_jobs/index.html.erb
|
|
152
140
|
- app/views/rails_nexus/database_health/index.html.erb
|
|
153
141
|
- app/views/rails_nexus/logged_exceptions/_comments_list.html.erb
|
|
@@ -171,18 +159,9 @@ files:
|
|
|
171
159
|
- config/initializers/date_formats.rb
|
|
172
160
|
- config/locales/en.yml
|
|
173
161
|
- config/routes.rb
|
|
174
|
-
- db/seeds/backup_configs.rb
|
|
175
|
-
- lib/generators/rails_nexus/backup_generator.rb
|
|
176
162
|
- lib/generators/rails_nexus/customize_generator.rb
|
|
177
163
|
- lib/generators/rails_nexus/install_generator.rb
|
|
178
164
|
- lib/generators/rails_nexus/templates/INSTALL.md
|
|
179
|
-
- lib/generators/rails_nexus/templates/backup/backup_helper.sh
|
|
180
|
-
- lib/generators/rails_nexus/templates/backup/config.rb
|
|
181
|
-
- lib/generators/rails_nexus/templates/backup/models/daily_backup.rb
|
|
182
|
-
- lib/generators/rails_nexus/templates/backup/models/full_backup.rb
|
|
183
|
-
- lib/generators/rails_nexus/templates/backup/models/sync_backup.rb
|
|
184
|
-
- lib/generators/rails_nexus/templates/backup/mysql-config/db_config.cnf
|
|
185
|
-
- lib/generators/rails_nexus/templates/backup/schedule.rb
|
|
186
165
|
- lib/generators/rails_nexus/templates/javascript/controllers/rails_nexus_controller.js
|
|
187
166
|
- lib/generators/rails_nexus/templates/javascript/controllers/rails_nexus_detail_controller.js
|
|
188
167
|
- lib/generators/rails_nexus/templates/javascript/controllers/rails_nexus_sidebar_controller.js
|
|
@@ -233,7 +212,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
233
212
|
- !ruby/object:Gem::Version
|
|
234
213
|
version: '0'
|
|
235
214
|
requirements: []
|
|
236
|
-
rubygems_version:
|
|
215
|
+
rubygems_version: 3.6.9
|
|
237
216
|
specification_version: 4
|
|
238
217
|
summary: The extensible control plane for Rails applications
|
|
239
218
|
test_files: []
|
|
@@ -1,119 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module RailsNexus
|
|
4
|
-
class BackupController < ApplicationController
|
|
5
|
-
before_action :check_backup_enabled
|
|
6
|
-
before_action :set_config, only: %i[edit update destroy trigger]
|
|
7
|
-
|
|
8
|
-
# GET /rails_nexus/backup
|
|
9
|
-
def index
|
|
10
|
-
@configs = RailsNexus::BackupConfig.order(:name)
|
|
11
|
-
end
|
|
12
|
-
|
|
13
|
-
# GET /rails_nexus/backup/new
|
|
14
|
-
def new
|
|
15
|
-
@config = RailsNexus::BackupConfig.new(
|
|
16
|
-
adapter: "mysql",
|
|
17
|
-
host: "localhost",
|
|
18
|
-
port: 3306,
|
|
19
|
-
storage_path: "~/dumps",
|
|
20
|
-
keep_count: 30,
|
|
21
|
-
compress: true
|
|
22
|
-
)
|
|
23
|
-
end
|
|
24
|
-
|
|
25
|
-
# POST /rails_nexus/backup
|
|
26
|
-
def create
|
|
27
|
-
@config = RailsNexus::BackupConfig.new(config_params)
|
|
28
|
-
|
|
29
|
-
if @config.save
|
|
30
|
-
redirect_to backup_path, notice: "Backup config '#{@config.name}' created."
|
|
31
|
-
else
|
|
32
|
-
render :new, status: :unprocessable_entity
|
|
33
|
-
end
|
|
34
|
-
end
|
|
35
|
-
|
|
36
|
-
# GET /rails_nexus/backup/:id/edit
|
|
37
|
-
def edit; end
|
|
38
|
-
|
|
39
|
-
# PATCH /rails_nexus/backup/:id
|
|
40
|
-
def update
|
|
41
|
-
# Strip blank password fields so they don't overwrite stored values
|
|
42
|
-
filtered = config_params
|
|
43
|
-
%w[password encryption_password gpg_password].each do |attr|
|
|
44
|
-
filtered = filtered.except(attr) if filtered[attr].blank?
|
|
45
|
-
end
|
|
46
|
-
|
|
47
|
-
if @config.update(filtered)
|
|
48
|
-
redirect_to backup_path, notice: "Backup config '#{@config.name}' updated."
|
|
49
|
-
else
|
|
50
|
-
render :edit, status: :unprocessable_entity
|
|
51
|
-
end
|
|
52
|
-
end
|
|
53
|
-
|
|
54
|
-
# DELETE /rails_nexus/backup/:id
|
|
55
|
-
def destroy
|
|
56
|
-
name = @config.name
|
|
57
|
-
@config.destroy
|
|
58
|
-
redirect_to backup_path, notice: "Backup config '#{name}' deleted."
|
|
59
|
-
end
|
|
60
|
-
|
|
61
|
-
# POST /rails_nexus/backup/:id/trigger
|
|
62
|
-
def trigger
|
|
63
|
-
begin
|
|
64
|
-
RailsNexus::BackupJob.perform_later(@config.id)
|
|
65
|
-
redirect_to backup_path, notice: "Backup '#{@config.name}' started in background."
|
|
66
|
-
rescue
|
|
67
|
-
# Fallback to synchronous if Active Job is not configured
|
|
68
|
-
result = RailsNexus::BackupService.run(@config)
|
|
69
|
-
if result[:success]
|
|
70
|
-
redirect_to backup_path, notice: "Backup '#{@config.name}' completed successfully."
|
|
71
|
-
else
|
|
72
|
-
redirect_to backup_path, alert: "Backup failed: #{result[:error]}"
|
|
73
|
-
end
|
|
74
|
-
end
|
|
75
|
-
end
|
|
76
|
-
|
|
77
|
-
# GET /rails_nexus/backup/:id/history
|
|
78
|
-
def history
|
|
79
|
-
@config = RailsNexus::BackupConfig.find(params[:id])
|
|
80
|
-
@records = RailsNexus::Backup.where(config_name: @config.name)
|
|
81
|
-
.order(started_at: :desc)
|
|
82
|
-
.limit(50)
|
|
83
|
-
end
|
|
84
|
-
|
|
85
|
-
# GET /rails_nexus/backup/history
|
|
86
|
-
def all_history
|
|
87
|
-
@records = RailsNexus::Backup.order(started_at: :desc).limit(100)
|
|
88
|
-
render :history
|
|
89
|
-
end
|
|
90
|
-
|
|
91
|
-
private
|
|
92
|
-
|
|
93
|
-
def set_config
|
|
94
|
-
@config = RailsNexus::BackupConfig.find(params[:id])
|
|
95
|
-
end
|
|
96
|
-
|
|
97
|
-
def config_params
|
|
98
|
-
params.require(:backup_config).permit(
|
|
99
|
-
:name, :description, :database_name, :adapter, :host, :port,
|
|
100
|
-
:username, :password, :storage_path, :keep_count,
|
|
101
|
-
:compress, :encrypted, :encryption_password,
|
|
102
|
-
:rsync_enabled, :rsync_host, :rsync_port, :rsync_user, :rsync_path, :rsync_mirror, :rsync_archive, :rsync_directories, :rsync_excludes,
|
|
103
|
-
:notify_command, :notify_on_success, :notify_on_failure,
|
|
104
|
-
:schedule_cron, :enabled,
|
|
105
|
-
:s3_enabled, :s3_access_key, :s3_secret_key, :s3_bucket, :s3_region, :s3_prefix,
|
|
106
|
-
:gpg_enabled, :gpg_password,
|
|
107
|
-
:email_notify, :email_to,
|
|
108
|
-
:archive_enabled, :bzip2_compress, :split_chunks, :encrypt_base64, :mysql_additional_options,
|
|
109
|
-
skip_tables: [], archive_paths: [], archive_excludes: []
|
|
110
|
-
)
|
|
111
|
-
end
|
|
112
|
-
|
|
113
|
-
def check_backup_enabled
|
|
114
|
-
unless RailsNexus.configuration.backup_enabled
|
|
115
|
-
render plain: "Backup management not enabled", status: :forbidden
|
|
116
|
-
end
|
|
117
|
-
end
|
|
118
|
-
end
|
|
119
|
-
end
|
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module RailsNexus
|
|
4
|
-
class BackupJob < ApplicationJob
|
|
5
|
-
queue_as :rails_nexus_backups
|
|
6
|
-
|
|
7
|
-
# Accept either a BackupConfig record ID or a BackupConfig record
|
|
8
|
-
def perform(backup_config_id_or_record)
|
|
9
|
-
config = if backup_config_id_or_record.is_a?(RailsNexus::BackupConfig)
|
|
10
|
-
backup_config_id_or_record
|
|
11
|
-
else
|
|
12
|
-
RailsNexus::BackupConfig.find(backup_config_id_or_record)
|
|
13
|
-
end
|
|
14
|
-
|
|
15
|
-
RailsNexus::BackupService.run(config)
|
|
16
|
-
end
|
|
17
|
-
end
|
|
18
|
-
end
|
|
@@ -1,107 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module RailsNexus
|
|
4
|
-
class Backup < BaseRecord
|
|
5
|
-
self.table_name = "rails_nexus_backups"
|
|
6
|
-
|
|
7
|
-
validates :config_name, presence: true
|
|
8
|
-
validates :status, presence: true, inclusion: { in: %w[running success failed] }
|
|
9
|
-
validates :started_at, presence: true
|
|
10
|
-
|
|
11
|
-
scope :recent, ->(hours = 24) { where("started_at >= ?", hours.hours.ago) }
|
|
12
|
-
scope :successful, -> { where(status: "success") }
|
|
13
|
-
scope :failed, -> { where(status: "failed") }
|
|
14
|
-
scope :running, -> { where(status: "running") }
|
|
15
|
-
scope :by_config, ->(name) { where(config_name: name) }
|
|
16
|
-
scope :latest_first, -> { order(started_at: :desc) }
|
|
17
|
-
|
|
18
|
-
# Start tracking a backup run
|
|
19
|
-
def self.start!(config_name:, triggered_by: "system")
|
|
20
|
-
create!(
|
|
21
|
-
config_name: config_name,
|
|
22
|
-
status: "running",
|
|
23
|
-
triggered_by: triggered_by,
|
|
24
|
-
started_at: Time.current
|
|
25
|
-
)
|
|
26
|
-
end
|
|
27
|
-
|
|
28
|
-
# Mark backup as completed
|
|
29
|
-
def succeed!(file_path: nil, file_size: nil)
|
|
30
|
-
update!(
|
|
31
|
-
status: "success",
|
|
32
|
-
file_path: file_path,
|
|
33
|
-
file_size: file_size,
|
|
34
|
-
duration: (Time.current - started_at).round(2),
|
|
35
|
-
completed_at: Time.current
|
|
36
|
-
)
|
|
37
|
-
end
|
|
38
|
-
|
|
39
|
-
# Mark backup as failed
|
|
40
|
-
def fail!(error_message:)
|
|
41
|
-
update!(
|
|
42
|
-
status: "failed",
|
|
43
|
-
error_message: error_message,
|
|
44
|
-
duration: (Time.current - started_at).round(2),
|
|
45
|
-
completed_at: Time.current
|
|
46
|
-
)
|
|
47
|
-
end
|
|
48
|
-
|
|
49
|
-
# Get success rate for a model over the last N days
|
|
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
|
-
return 0 if records.empty?
|
|
53
|
-
(records.successful.count.to_f / records.count * 100).round(1)
|
|
54
|
-
end
|
|
55
|
-
|
|
56
|
-
# Get backup health summary
|
|
57
|
-
def self.health_summary(alert_threshold_hours: 24)
|
|
58
|
-
configs = RailsNexus::BackupConfig.pluck(:name)
|
|
59
|
-
results = configs.map do |config_name|
|
|
60
|
-
latest = by_config(config_name).successful.latest_first.first
|
|
61
|
-
age_hours = latest ? ((Time.current - latest.started_at) / 3600).round(1) : nil
|
|
62
|
-
|
|
63
|
-
{
|
|
64
|
-
config_name: config_name,
|
|
65
|
-
last_backup: latest&.started_at,
|
|
66
|
-
age_hours: age_hours,
|
|
67
|
-
status: age_hours.nil? ? "unknown" : (age_hours < alert_threshold_hours ? "healthy" : "stale"),
|
|
68
|
-
success_rate: success_rate(config_name: config_name),
|
|
69
|
-
last_file_size: latest&.file_size
|
|
70
|
-
}
|
|
71
|
-
end
|
|
72
|
-
|
|
73
|
-
{
|
|
74
|
-
healthy: results.all? { |r| r[:status] == "healthy" },
|
|
75
|
-
models: results,
|
|
76
|
-
alerts: results.select { |r| r[:status] == "stale" || r[:status] == "unknown" }
|
|
77
|
-
}
|
|
78
|
-
end
|
|
79
|
-
|
|
80
|
-
# Cleanup old backup records
|
|
81
|
-
def self.cleanup!(retention_days: 30)
|
|
82
|
-
where("started_at < ?", retention_days.days.ago).delete_all
|
|
83
|
-
end
|
|
84
|
-
|
|
85
|
-
# Format file size
|
|
86
|
-
def file_size_human
|
|
87
|
-
return "—" unless file_size
|
|
88
|
-
units = ["B", "KB", "MB", "GB"]
|
|
89
|
-
size = file_size.to_f
|
|
90
|
-
units.each do |unit|
|
|
91
|
-
return "#{size.round(1)} #{unit}" if size < 1024
|
|
92
|
-
size /= 1024
|
|
93
|
-
end
|
|
94
|
-
"#{size.round(1)} TB"
|
|
95
|
-
end
|
|
96
|
-
|
|
97
|
-
# Duration formatted
|
|
98
|
-
def duration_human
|
|
99
|
-
return "—" unless duration
|
|
100
|
-
if duration < 60
|
|
101
|
-
"#{duration.round(1)}s"
|
|
102
|
-
else
|
|
103
|
-
"#{(duration / 60).floor}m #{(duration % 60).round}s"
|
|
104
|
-
end
|
|
105
|
-
end
|
|
106
|
-
end
|
|
107
|
-
end
|
|
@@ -1,189 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module RailsNexus
|
|
4
|
-
class BackupConfig < BaseRecord
|
|
5
|
-
self.table_name = "rails_nexus_backup_configs"
|
|
6
|
-
|
|
7
|
-
ADAPTERS = %w[mysql postgresql sqlite mongodb redis].freeze
|
|
8
|
-
|
|
9
|
-
# ─── Boolean defaults ──────────────────────────────────────────
|
|
10
|
-
# Rails doesn't apply DB defaults to new in-memory records, so
|
|
11
|
-
# check_box helpers in form_for render unchecked. These declarations
|
|
12
|
-
# ensure the form reflects the intended defaults.
|
|
13
|
-
attribute :enabled, :boolean, default: true
|
|
14
|
-
attribute :compress, :boolean, default: true
|
|
15
|
-
attribute :encrypted, :boolean, default: false
|
|
16
|
-
attribute :encrypt_base64, :boolean, default: false
|
|
17
|
-
attribute :rsync_enabled, :boolean, default: false
|
|
18
|
-
attribute :rsync_mirror, :boolean, default: false
|
|
19
|
-
attribute :notify_on_success, :boolean, default: true
|
|
20
|
-
attribute :notify_on_failure, :boolean, default: true
|
|
21
|
-
attribute :s3_enabled, :boolean, default: false
|
|
22
|
-
attribute :gpg_enabled, :boolean, default: false
|
|
23
|
-
attribute :email_notify, :boolean, default: false
|
|
24
|
-
attribute :archive_enabled, :boolean, default: false
|
|
25
|
-
attribute :bzip2_compress, :boolean, default: false
|
|
26
|
-
attribute :split_chunks, :boolean, default: false
|
|
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
|
-
}
|
|
33
|
-
validates :database_name, presence: true, if: -> { adapter != "redis" }
|
|
34
|
-
validates :adapter, presence: true, inclusion: { in: ADAPTERS }
|
|
35
|
-
validates :storage_path, presence: true
|
|
36
|
-
validates :keep_count, numericality: { greater_than: 0 }
|
|
37
|
-
validates :encryption_password, presence: true, if: :encrypted?
|
|
38
|
-
validates :rsync_host, presence: true, if: :rsync_enabled?
|
|
39
|
-
validates :rsync_user, presence: true, if: :rsync_enabled?
|
|
40
|
-
validates :s3_bucket, presence: true, if: :s3_enabled?
|
|
41
|
-
validates :s3_access_key, presence: true, if: :s3_enabled?
|
|
42
|
-
validates :s3_secret_key, presence: true, if: :s3_enabled?
|
|
43
|
-
validates :gpg_password, presence: true, if: :gpg_enabled?
|
|
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
|
|
50
|
-
|
|
51
|
-
scope :enabled, -> { where(enabled: true) }
|
|
52
|
-
scope :disabled, -> { where(enabled: false) }
|
|
53
|
-
|
|
54
|
-
serialize :skip_tables, coder: JSON
|
|
55
|
-
serialize :archive_paths, coder: JSON
|
|
56
|
-
serialize :archive_excludes, coder: JSON
|
|
57
|
-
|
|
58
|
-
# ─── Skip Tables ───────────────────────────────────────────────
|
|
59
|
-
def skip_tables_list
|
|
60
|
-
return [] if skip_tables.blank?
|
|
61
|
-
skip_tables.is_a?(Array) ? skip_tables : JSON.parse(skip_tables.to_s) rescue []
|
|
62
|
-
end
|
|
63
|
-
|
|
64
|
-
def skip_tables_list=(value)
|
|
65
|
-
return if value.blank?
|
|
66
|
-
self.skip_tables = if value.is_a?(String)
|
|
67
|
-
value.split(",").map(&:strip).reject(&:blank?)
|
|
68
|
-
else
|
|
69
|
-
value
|
|
70
|
-
end
|
|
71
|
-
end
|
|
72
|
-
|
|
73
|
-
# ─── Archive Paths ─────────────────────────────────────────────
|
|
74
|
-
def archive_paths_list
|
|
75
|
-
return [] if archive_paths.blank?
|
|
76
|
-
archive_paths.is_a?(Array) ? archive_paths : JSON.parse(archive_paths.to_s) rescue []
|
|
77
|
-
end
|
|
78
|
-
|
|
79
|
-
def archive_paths_list=(value)
|
|
80
|
-
return if value.blank?
|
|
81
|
-
self.archive_paths = if value.is_a?(String)
|
|
82
|
-
value.split(",").map(&:strip).reject(&:blank?)
|
|
83
|
-
else
|
|
84
|
-
value
|
|
85
|
-
end
|
|
86
|
-
end
|
|
87
|
-
|
|
88
|
-
def archive_excludes_list
|
|
89
|
-
return [] if archive_excludes.blank?
|
|
90
|
-
archive_excludes.is_a?(Array) ? archive_excludes : JSON.parse(archive_excludes.to_s) rescue []
|
|
91
|
-
end
|
|
92
|
-
|
|
93
|
-
def archive_excludes_list=(value)
|
|
94
|
-
return if value.blank?
|
|
95
|
-
self.archive_excludes = if value.is_a?(String)
|
|
96
|
-
value.split(",").map(&:strip).reject(&:blank?)
|
|
97
|
-
else
|
|
98
|
-
value
|
|
99
|
-
end
|
|
100
|
-
end
|
|
101
|
-
|
|
102
|
-
# ─── Adapter checks ────────────────────────────────────────────
|
|
103
|
-
def mysql?; adapter == "mysql"; end
|
|
104
|
-
def postgresql?; adapter == "postgresql"; end
|
|
105
|
-
def sqlite?; adapter == "sqlite"; end
|
|
106
|
-
def mongodb?; adapter == "mongodb"; end
|
|
107
|
-
def redis?; adapter == "redis"; end
|
|
108
|
-
|
|
109
|
-
# ─── Path helpers ──────────────────────────────────────────────
|
|
110
|
-
def storage_path_expanded
|
|
111
|
-
storage_path.gsub("~", Dir.home)
|
|
112
|
-
end
|
|
113
|
-
|
|
114
|
-
def dump_filename
|
|
115
|
-
timestamp = Time.current.strftime("%Y%m%d_%H%M%S")
|
|
116
|
-
ext = dump_extension
|
|
117
|
-
"#{name}_#{timestamp}.#{ext}"
|
|
118
|
-
end
|
|
119
|
-
|
|
120
|
-
def dump_filepath
|
|
121
|
-
File.join(storage_path_expanded, dump_filename)
|
|
122
|
-
end
|
|
123
|
-
|
|
124
|
-
# ─── Stats ─────────────────────────────────────────────────────
|
|
125
|
-
def recent_backups(limit: 10)
|
|
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
|
|
131
|
-
end
|
|
132
|
-
|
|
133
|
-
def stats
|
|
134
|
-
backups = RailsNexus::Backup.where(config_name: name)
|
|
135
|
-
recent = backups.where("started_at >= ?", 7.days.ago)
|
|
136
|
-
{
|
|
137
|
-
total: backups.count,
|
|
138
|
-
successful: backups.successful.count,
|
|
139
|
-
failed: backups.failed.count,
|
|
140
|
-
recent_count: recent.count,
|
|
141
|
-
recent_success: recent.successful.count,
|
|
142
|
-
recent_failed: recent.failed.count,
|
|
143
|
-
last_backup: backups.successful.latest_first.first,
|
|
144
|
-
total_size: backups.sum(:file_size).to_i,
|
|
145
|
-
total_size_human: human_size(backups.sum(:file_size).to_i)
|
|
146
|
-
}
|
|
147
|
-
end
|
|
148
|
-
|
|
149
|
-
private
|
|
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
|
-
|
|
173
|
-
def dump_extension
|
|
174
|
-
parts = ["sql"]
|
|
175
|
-
parts << "gz" if compress? && !bzip2_compress?
|
|
176
|
-
parts << "bz2" if bzip2_compress?
|
|
177
|
-
parts << "enc" if encrypted?
|
|
178
|
-
parts.join(".")
|
|
179
|
-
end
|
|
180
|
-
|
|
181
|
-
def human_size(bytes)
|
|
182
|
-
return "0 B" if bytes.zero?
|
|
183
|
-
units = %w[B KB MB GB TB]
|
|
184
|
-
exp = (Math.log(bytes) / Math.log(1024)).to_i
|
|
185
|
-
exp = units.size - 1 if exp >= units.size
|
|
186
|
-
"%.1f %s" % [bytes.to_f / (1024**exp), units[exp]]
|
|
187
|
-
end
|
|
188
|
-
end
|
|
189
|
-
end
|
|
@@ -1,62 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module RailsNexus
|
|
4
|
-
class BackupRunner
|
|
5
|
-
# Run a specific backup config by name
|
|
6
|
-
def self.run(name)
|
|
7
|
-
config = RailsNexus::BackupConfig.find_by!(name: name)
|
|
8
|
-
RailsNexus::BackupService.run(config)
|
|
9
|
-
rescue ActiveRecord::RecordNotFound
|
|
10
|
-
{ success: false, error: "Backup config '#{name}' not found" }
|
|
11
|
-
end
|
|
12
|
-
|
|
13
|
-
# Run all enabled backup configs
|
|
14
|
-
def self.run_all
|
|
15
|
-
results = []
|
|
16
|
-
RailsNexus::BackupConfig.enabled.find_each do |config|
|
|
17
|
-
results << RailsNexus::BackupService.run(config)
|
|
18
|
-
end
|
|
19
|
-
results
|
|
20
|
-
end
|
|
21
|
-
|
|
22
|
-
# Run backups matching a cron schedule
|
|
23
|
-
def self.run_scheduled
|
|
24
|
-
now = Time.current
|
|
25
|
-
results = []
|
|
26
|
-
|
|
27
|
-
RailsNexus::BackupConfig.enabled.where.not(schedule_cron: [nil, ""]).find_each do |config|
|
|
28
|
-
if matches_cron?(config.schedule_cron, now)
|
|
29
|
-
results << RailsNexus::BackupService.run(config)
|
|
30
|
-
end
|
|
31
|
-
end
|
|
32
|
-
|
|
33
|
-
results
|
|
34
|
-
end
|
|
35
|
-
|
|
36
|
-
# Check if a cron expression matches the current time
|
|
37
|
-
def self.matches_cron?(cron_expression, time)
|
|
38
|
-
return false if cron_expression.blank?
|
|
39
|
-
|
|
40
|
-
parts = cron_expression.strip.split(/\s+/)
|
|
41
|
-
return false unless parts.size == 5
|
|
42
|
-
|
|
43
|
-
minute, hour, day, month, wday = parts
|
|
44
|
-
|
|
45
|
-
match_field(minute, time.min) &&
|
|
46
|
-
match_field(hour, time.hour) &&
|
|
47
|
-
match_field(day, time.day) &&
|
|
48
|
-
match_field(month, time.mon) &&
|
|
49
|
-
match_field(wday, time.wday)
|
|
50
|
-
end
|
|
51
|
-
|
|
52
|
-
def self.match_field(pattern, value)
|
|
53
|
-
return true if pattern == "*"
|
|
54
|
-
return true if pattern.include?(",") && pattern.split(",").map(&:strip).include?(value.to_s)
|
|
55
|
-
return true if pattern.include?("-")
|
|
56
|
-
return true if pattern.include?("/")
|
|
57
|
-
|
|
58
|
-
pattern.to_i == value
|
|
59
|
-
end
|
|
60
|
-
private_class_method :match_field
|
|
61
|
-
end
|
|
62
|
-
end
|