rails_nexus 2.0.1 → 2.0.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/app/controllers/rails_nexus/backup_controller.rb +78 -24
- data/app/models/rails_nexus/backup_config.rb +90 -0
- data/app/services/rails_nexus/backup_runner.rb +62 -0
- data/app/services/rails_nexus/backup_service.rb +231 -131
- data/app/views/rails_nexus/backup/_form.html.erb +185 -0
- data/app/views/rails_nexus/backup/config_history.html.erb +67 -0
- data/app/views/rails_nexus/backup/edit.html.erb +15 -0
- data/app/views/rails_nexus/backup/{files.html.erb → history.html.erb} +8 -15
- data/app/views/rails_nexus/backup/index.html.erb +99 -79
- data/app/views/rails_nexus/backup/new.html.erb +15 -0
- data/config/routes.rb +8 -4
- data/lib/generators/rails_nexus/templates/rails_nexus.rb +1 -1
- data/lib/rails_nexus/version.rb +1 -1
- data/lib/tasks/rails_nexus.rake +66 -3
- metadata +8 -3
- data/config/initializers/rails_nexus.rb +0 -18
|
@@ -1,176 +1,276 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "open3"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
3
6
|
module RailsNexus
|
|
4
7
|
class BackupService
|
|
5
|
-
|
|
8
|
+
# Execute a backup for a given config
|
|
9
|
+
def self.run(config)
|
|
10
|
+
new(config).run
|
|
11
|
+
end
|
|
6
12
|
|
|
7
|
-
def initialize
|
|
8
|
-
@
|
|
13
|
+
def initialize(config)
|
|
14
|
+
@config = config
|
|
15
|
+
@record = nil
|
|
16
|
+
@errors = []
|
|
9
17
|
end
|
|
10
18
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
19
|
+
def run
|
|
20
|
+
@record = RailsNexus::Backup.start!(
|
|
21
|
+
model_name: @config.name,
|
|
22
|
+
triggered_by: "service"
|
|
23
|
+
)
|
|
14
24
|
|
|
15
25
|
begin
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
26
|
+
# Ensure storage directory exists
|
|
27
|
+
FileUtils.mkdir_p(@config.storage_path_expanded)
|
|
28
|
+
|
|
29
|
+
# Step 1: Dump database
|
|
30
|
+
dump_path = dump_database
|
|
31
|
+
return fail_record("Database dump failed") unless dump_path
|
|
32
|
+
|
|
33
|
+
# Step 2: Compress if configured
|
|
34
|
+
final_path = @config.compress? ? compress(dump_path) : dump_path
|
|
35
|
+
|
|
36
|
+
# Step 3: Encrypt if configured
|
|
37
|
+
final_path = encrypt(final_path) if @config.encrypt?
|
|
38
|
+
|
|
39
|
+
# Step 4: Sync to remote if configured
|
|
40
|
+
rsync_result = sync_remote(final_path) if @config.rsync_enabled?
|
|
41
|
+
|
|
42
|
+
# Step 5: Cleanup old backups
|
|
43
|
+
cleanup_old_backups
|
|
44
|
+
|
|
45
|
+
# Step 6: Record success
|
|
46
|
+
file_size = File.exist?(final_path) ? File.size(final_path) : 0
|
|
47
|
+
@record.succeed!(
|
|
48
|
+
file_path: final_path,
|
|
49
|
+
file_size: file_size
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
# Step 7: Notify
|
|
53
|
+
notify_success
|
|
54
|
+
|
|
55
|
+
{ success: true, record: @record, file_path: final_path }
|
|
35
56
|
rescue StandardError => e
|
|
36
|
-
record.fail!(error_message: e.message)
|
|
37
|
-
|
|
57
|
+
@record.fail!(error_message: e.message) if @record
|
|
58
|
+
notify_failure(e.message)
|
|
59
|
+
{ success: false, error: e.message, record: @record }
|
|
60
|
+
ensure
|
|
61
|
+
# Clean up temp files
|
|
62
|
+
cleanup_temp_files
|
|
38
63
|
end
|
|
39
64
|
end
|
|
40
65
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
successful: RailsNexus::Backup.successful.count,
|
|
52
|
-
failed: RailsNexus::Backup.failed.count,
|
|
53
|
-
recent_count: recent.count,
|
|
54
|
-
recent_success: recent.successful.count,
|
|
55
|
-
recent_failed: recent.failed.count,
|
|
56
|
-
total_size: RailsNexus::Backup.sum(:file_size).to_i,
|
|
57
|
-
total_size_human: human_size(RailsNexus::Backup.sum(:file_size).to_i),
|
|
58
|
-
last_backup: RailsNexus::Backup.successful.latest_first.first
|
|
59
|
-
}
|
|
60
|
-
end
|
|
61
|
-
|
|
62
|
-
# Get health status based on table records
|
|
63
|
-
def health_status
|
|
64
|
-
last = RailsNexus::Backup.successful.latest_first.first
|
|
65
|
-
threshold = RailsNexus.configuration.backup_alert_threshold_hours || 24
|
|
66
|
-
|
|
67
|
-
if last.nil?
|
|
68
|
-
{ status: "warning", message: "No backups recorded yet" }
|
|
69
|
-
elsif last.duration && last.duration > threshold * 3600
|
|
70
|
-
{ status: "critical", message: "Last backup #{((Time.current - last.started_at) / 3600).round(1)}h ago" }
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def dump_database
|
|
69
|
+
case @config.adapter
|
|
70
|
+
when "mysql"
|
|
71
|
+
dump_mysql
|
|
72
|
+
when "postgresql"
|
|
73
|
+
dump_postgresql
|
|
74
|
+
when "sqlite"
|
|
75
|
+
dump_sqlite
|
|
71
76
|
else
|
|
72
|
-
|
|
77
|
+
raise "Unsupported adapter: #{@config.adapter}"
|
|
73
78
|
end
|
|
74
79
|
end
|
|
75
80
|
|
|
76
|
-
#
|
|
77
|
-
def
|
|
78
|
-
|
|
79
|
-
|
|
81
|
+
# ─── MySQL Dump ──────────────────────────────────────────────────
|
|
82
|
+
def dump_mysql
|
|
83
|
+
raw_path = @config.dump_filepath.gsub(/\.gz$/, "")
|
|
84
|
+
cmd = build_mysql_command(raw_path)
|
|
80
85
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
86
|
+
stdout, stderr, status = Open3.capture3(cmd)
|
|
87
|
+
unless status.success?
|
|
88
|
+
@errors << "mysqldump failed: #{stderr}"
|
|
89
|
+
return nil
|
|
84
90
|
end
|
|
85
91
|
|
|
86
|
-
|
|
87
|
-
{ deleted: count }
|
|
92
|
+
raw_path
|
|
88
93
|
end
|
|
89
94
|
|
|
90
|
-
|
|
95
|
+
def build_mysql_command(output_path)
|
|
96
|
+
parts = ["mysqldump"]
|
|
97
|
+
parts << "--user=#{@config.username}" if @config.username.present?
|
|
98
|
+
parts << "--password=#{@config.password}" if @config.password.present?
|
|
99
|
+
parts << "--host=#{@config.host}" if @config.host.present?
|
|
100
|
+
parts << "--port=#{@config.port}" if @config.port.present?
|
|
101
|
+
parts << "--result-file=#{output_path}"
|
|
102
|
+
parts << "--quick"
|
|
103
|
+
parts << "--single-transaction" if @config.mysql?
|
|
91
104
|
|
|
92
|
-
|
|
93
|
-
|
|
105
|
+
# Skip tables
|
|
106
|
+
skip = @config.skip_tables_list
|
|
107
|
+
skip.each { |t| parts << "--ignore-table=#{@config.database_name}.#{t}" }
|
|
108
|
+
|
|
109
|
+
parts << @config.database_name
|
|
110
|
+
parts.join(" ")
|
|
94
111
|
end
|
|
95
112
|
|
|
96
|
-
#
|
|
97
|
-
def
|
|
98
|
-
|
|
99
|
-
|
|
113
|
+
# ─── PostgreSQL Dump ─────────────────────────────────────────────
|
|
114
|
+
def dump_postgresql
|
|
115
|
+
raw_path = @config.dump_filepath.gsub(/\.gz$/, "")
|
|
116
|
+
cmd = build_postgresql_command(raw_path)
|
|
100
117
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
else
|
|
109
|
-
{ success: false, error: "Unsupported adapter: #{adapter}" }
|
|
118
|
+
env = {}
|
|
119
|
+
env["PGPASSWORD"] = @config.password if @config.password.present?
|
|
120
|
+
|
|
121
|
+
stdout, stderr, status = Open3.capture3(env, cmd)
|
|
122
|
+
unless status.success?
|
|
123
|
+
@errors << "pg_dump failed: #{stderr}"
|
|
124
|
+
return nil
|
|
110
125
|
end
|
|
126
|
+
|
|
127
|
+
raw_path
|
|
111
128
|
end
|
|
112
129
|
|
|
113
|
-
def
|
|
114
|
-
|
|
115
|
-
|
|
130
|
+
def build_postgresql_command(output_path)
|
|
131
|
+
parts = ["pg_dump"]
|
|
132
|
+
parts << "--username=#{@config.username}" if @config.username.present?
|
|
133
|
+
parts << "--host=#{@config.host}" if @config.host.present?
|
|
134
|
+
parts << "--port=#{@config.port}" if @config.port.present?
|
|
135
|
+
parts << "--file=#{output_path}"
|
|
136
|
+
parts << "--format=plain"
|
|
116
137
|
|
|
117
|
-
#
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
138
|
+
# Skip tables
|
|
139
|
+
skip = @config.skip_tables_list
|
|
140
|
+
skip.each { |t| parts << "--exclude-table=#{t}" }
|
|
141
|
+
|
|
142
|
+
parts << @config.database_name
|
|
143
|
+
parts.join(" ")
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# ─── SQLite Dump ─────────────────────────────────────────────────
|
|
147
|
+
def dump_sqlite
|
|
148
|
+
raw_path = @config.dump_filepath.gsub(/\.gz$/, "")
|
|
149
|
+
db_path = @config.database_name
|
|
150
|
+
|
|
151
|
+
unless File.exist?(db_path)
|
|
152
|
+
@errors << "SQLite database not found: #{db_path}"
|
|
153
|
+
return nil
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
stdout, stderr, status = Open3.capture3("sqlite3 #{db_path} .dump")
|
|
157
|
+
unless status.success?
|
|
158
|
+
@errors << "sqlite3 dump failed: #{stderr}"
|
|
159
|
+
return nil
|
|
124
160
|
end
|
|
161
|
+
|
|
162
|
+
File.write(raw_path, stdout)
|
|
163
|
+
raw_path
|
|
125
164
|
end
|
|
126
165
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
"--result-file=#{file_path}",
|
|
135
|
-
config[:database]
|
|
136
|
-
].compact.join(" ")
|
|
137
|
-
|
|
138
|
-
output = `#{cmd} 2>&1`
|
|
139
|
-
if $?.success?
|
|
140
|
-
{ success: true }
|
|
141
|
-
else
|
|
142
|
-
{ success: false, error: "mysqldump failed: #{output}" }
|
|
166
|
+
# ─── Compression ─────────────────────────────────────────────────
|
|
167
|
+
def compress(file_path)
|
|
168
|
+
gz_path = "#{file_path}.gz"
|
|
169
|
+
File.open(file_path, "rb") do |input|
|
|
170
|
+
Zlib::GzipWriter.open(gz_path) do |gz|
|
|
171
|
+
gz.write(input.read)
|
|
172
|
+
end
|
|
143
173
|
end
|
|
174
|
+
File.delete(file_path) if File.exist?(gz_path)
|
|
175
|
+
gz_path
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# ─── Encryption ──────────────────────────────────────────────────
|
|
179
|
+
def encrypt(file_path)
|
|
180
|
+
enc_path = "#{file_path}.enc"
|
|
181
|
+
password = @config.encrypt_password
|
|
182
|
+
|
|
183
|
+
cmd = "openssl aes-256-cbc -salt -pbkdf2 -in #{file_path} -out #{enc_path} -pass pass:#{password}"
|
|
184
|
+
stdout, stderr, status = Open3.capture3(cmd)
|
|
185
|
+
|
|
186
|
+
unless status.success?
|
|
187
|
+
@errors << "Encryption failed: #{stderr}"
|
|
188
|
+
return file_path
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
File.delete(file_path) if File.exist?(enc_path)
|
|
192
|
+
enc_path
|
|
144
193
|
end
|
|
145
194
|
|
|
146
|
-
|
|
195
|
+
# ─── RSync Remote Sync ──────────────────────────────────────────
|
|
196
|
+
def sync_remote(file_path)
|
|
197
|
+
return unless @config.rsync_enabled?
|
|
198
|
+
return unless @config.rsync_host.present?
|
|
199
|
+
|
|
200
|
+
remote = "#{@config.rsync_user}@#{@config.rsync_host}:#{@config.rsync_path}/"
|
|
201
|
+
|
|
147
202
|
cmd = [
|
|
148
|
-
"
|
|
149
|
-
"
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
].
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
if output.last.success?
|
|
160
|
-
{ success: true }
|
|
161
|
-
else
|
|
162
|
-
{ success: false, error: "pg_dump failed: #{output.first}" }
|
|
203
|
+
"rsync",
|
|
204
|
+
"-avz",
|
|
205
|
+
"--progress",
|
|
206
|
+
"-e", "ssh -p #{@config.rsync_port}",
|
|
207
|
+
file_path,
|
|
208
|
+
remote
|
|
209
|
+
].join(" ")
|
|
210
|
+
|
|
211
|
+
stdout, stderr, status = Open3.capture3(cmd)
|
|
212
|
+
unless status.success?
|
|
213
|
+
@errors << "RSync failed: #{stderr}"
|
|
163
214
|
end
|
|
215
|
+
|
|
216
|
+
{ success: status.success?, output: stdout }
|
|
164
217
|
end
|
|
165
218
|
|
|
166
|
-
|
|
167
|
-
|
|
219
|
+
# ─── Cleanup ─────────────────────────────────────────────────────
|
|
220
|
+
def cleanup_old_backups
|
|
221
|
+
storage = @config.storage_path_expanded
|
|
222
|
+
return unless Dir.exist?(storage)
|
|
223
|
+
|
|
224
|
+
pattern = File.join(storage, "#{@config.name}_*")
|
|
225
|
+
files = Dir.glob(pattern).sort_by { |f| File.mtime(f) }
|
|
168
226
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
227
|
+
# Keep only keep_count most recent
|
|
228
|
+
while files.size > @config.keep_count
|
|
229
|
+
old_file = files.shift
|
|
230
|
+
File.delete(old_file) if File.exist?(old_file)
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def cleanup_temp_files
|
|
235
|
+
# Clean up any intermediate files (uncompressed, unencrypted)
|
|
236
|
+
storage = @config.storage_path_expanded
|
|
237
|
+
return unless Dir.exist?(storage)
|
|
238
|
+
|
|
239
|
+
Dir.glob(File.join(storage, "#{@config.name}_*.sql")).each do |f|
|
|
240
|
+
File.delete(f) if File.exist?("#{f}.gz") || File.exist?("#{f}.enc")
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# ─── Notifications ───────────────────────────────────────────────
|
|
245
|
+
def notify_success
|
|
246
|
+
return unless @config.notify_on_success?
|
|
247
|
+
execute_notify_command("success")
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def notify_failure(error_message)
|
|
251
|
+
return unless @config.notify_on_failure?
|
|
252
|
+
execute_notify_command("failure", error_message)
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def execute_notify_command(status, error = nil)
|
|
256
|
+
return if @config.notify_command.blank?
|
|
257
|
+
|
|
258
|
+
cmd = @config.notify_command
|
|
259
|
+
.gsub("{STATUS}", status.to_s)
|
|
260
|
+
.gsub("{MODEL}", @config.name)
|
|
261
|
+
.gsub("{ERROR}", error.to_s)
|
|
262
|
+
.gsub("{TIME}", Time.current.iso8601)
|
|
263
|
+
|
|
264
|
+
Open3.capture3(cmd)
|
|
265
|
+
rescue StandardError
|
|
266
|
+
# Don't fail backup because notification failed
|
|
267
|
+
end
|
|
172
268
|
|
|
173
|
-
|
|
269
|
+
# ─── Fail Record ─────────────────────────────────────────────────
|
|
270
|
+
def fail_record(message)
|
|
271
|
+
@record.fail!(error_message: message) if @record
|
|
272
|
+
notify_failure(message)
|
|
273
|
+
{ success: false, error: message, record: @record }
|
|
174
274
|
end
|
|
175
275
|
end
|
|
176
276
|
end
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
<%= form_for @config, url: (@config.persisted? ? backup_path(@config) : backup_index_path), class: "space-y-6" do |f| %>
|
|
2
|
+
<% if @config.errors.any? %>
|
|
3
|
+
<div class="rn-card" style="border-left: 3px solid var(--rn-danger)">
|
|
4
|
+
<div class="p-4">
|
|
5
|
+
<p class="text-sm font-semibold" style="color: var(--rn-danger)"><%= @config.errors.count %> error(s) prevented this config from being saved:</p>
|
|
6
|
+
<ul class="mt-2 text-xs" style="color: var(--rn-text-secondary)">
|
|
7
|
+
<% @config.errors.full_messages.each do |msg| %>
|
|
8
|
+
<li>• <%= msg %></li>
|
|
9
|
+
<% end %>
|
|
10
|
+
</ul>
|
|
11
|
+
</div>
|
|
12
|
+
</div>
|
|
13
|
+
<% end %>
|
|
14
|
+
|
|
15
|
+
<%# ─── General ─────────────────────────────────────────────── %>
|
|
16
|
+
<div class="rn-card">
|
|
17
|
+
<div class="p-4" style="border-bottom: 1px solid var(--rn-border-light)">
|
|
18
|
+
<h2 class="text-sm font-semibold" style="color: var(--rn-text)">General</h2>
|
|
19
|
+
</div>
|
|
20
|
+
<div class="p-4 grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
21
|
+
<div class="rn-filter-group">
|
|
22
|
+
<label>Name *</label>
|
|
23
|
+
<%= f.text_field :name, class: "rn-input", placeholder: "e.g. daily_backup", required: true %>
|
|
24
|
+
</div>
|
|
25
|
+
<div class="rn-filter-group">
|
|
26
|
+
<label>Description</label>
|
|
27
|
+
<%= f.text_field :description, class: "rn-input", placeholder: "e.g. TTI Daily Backup" %>
|
|
28
|
+
</div>
|
|
29
|
+
<div class="rn-filter-group">
|
|
30
|
+
<label>Enabled</label>
|
|
31
|
+
<%= f.check_box :enabled, class: "rn-input" %>
|
|
32
|
+
</div>
|
|
33
|
+
</div>
|
|
34
|
+
</div>
|
|
35
|
+
|
|
36
|
+
<%# ─── Database ────────────────────────────────────────────── %>
|
|
37
|
+
<div class="rn-card">
|
|
38
|
+
<div class="p-4" style="border-bottom: 1px solid var(--rn-border-light)">
|
|
39
|
+
<h2 class="text-sm font-semibold" style="color: var(--rn-text)">Database</h2>
|
|
40
|
+
</div>
|
|
41
|
+
<div class="p-4 grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
42
|
+
<div class="rn-filter-group">
|
|
43
|
+
<label>Adapter *</label>
|
|
44
|
+
<%= f.select :adapter, options_for_select([["MySQL", "mysql"], ["PostgreSQL", "postgresql"], ["SQLite", "sqlite"]], @config.adapter), {}, class: "rn-select" %>
|
|
45
|
+
</div>
|
|
46
|
+
<div class="rn-filter-group">
|
|
47
|
+
<label>Database Name *</label>
|
|
48
|
+
<%= f.text_field :database_name, class: "rn-input", placeholder: "e.g. myapp_production" %>
|
|
49
|
+
</div>
|
|
50
|
+
<div class="rn-filter-group">
|
|
51
|
+
<label>Host</label>
|
|
52
|
+
<%= f.text_field :host, class: "rn-input", placeholder: "localhost" %>
|
|
53
|
+
</div>
|
|
54
|
+
<div class="rn-filter-group">
|
|
55
|
+
<label>Port</label>
|
|
56
|
+
<%= f.number_field :port, class: "rn-input", placeholder: "3306" %>
|
|
57
|
+
</div>
|
|
58
|
+
<div class="rn-filter-group">
|
|
59
|
+
<label>Username</label>
|
|
60
|
+
<%= f.text_field :username, class: "rn-input", placeholder: "root" %>
|
|
61
|
+
</div>
|
|
62
|
+
<div class="rn-filter-group">
|
|
63
|
+
<label>Password</label>
|
|
64
|
+
<%= f.password_field :password, class: "rn-input", placeholder: "••••••••" %>
|
|
65
|
+
</div>
|
|
66
|
+
<div class="rn-filter-group md:col-span-3">
|
|
67
|
+
<label>Skip Tables (comma-separated)</label>
|
|
68
|
+
<%= text_field_tag "backup_config[skip_tables][]", @config.skip_tables_list.join(", "), class: "rn-input", placeholder: "e.g. sessions, versions" %>
|
|
69
|
+
</div>
|
|
70
|
+
</div>
|
|
71
|
+
</div>
|
|
72
|
+
|
|
73
|
+
<%# ─── Storage ─────────────────────────────────────────────── %>
|
|
74
|
+
<div class="rn-card">
|
|
75
|
+
<div class="p-4" style="border-bottom: 1px solid var(--rn-border-light)">
|
|
76
|
+
<h2 class="text-sm font-semibold" style="color: var(--rn-text)">Storage</h2>
|
|
77
|
+
</div>
|
|
78
|
+
<div class="p-4 grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
79
|
+
<div class="rn-filter-group">
|
|
80
|
+
<label>Storage Path *</label>
|
|
81
|
+
<%= f.text_field :storage_path, class: "rn-input", placeholder: "~/dumps" %>
|
|
82
|
+
</div>
|
|
83
|
+
<div class="rn-filter-group">
|
|
84
|
+
<label>Keep Count</label>
|
|
85
|
+
<%= f.number_field :keep_count, class: "rn-input", min: 1 %>
|
|
86
|
+
</div>
|
|
87
|
+
</div>
|
|
88
|
+
</div>
|
|
89
|
+
|
|
90
|
+
<%# ─── Compression & Encryption ────────────────────────────── %>
|
|
91
|
+
<div class="rn-card">
|
|
92
|
+
<div class="p-4" style="border-bottom: 1px solid var(--rn-border-light)">
|
|
93
|
+
<h2 class="text-sm font-semibold" style="color: var(--rn-text)">Compression & Encryption</h2>
|
|
94
|
+
</div>
|
|
95
|
+
<div class="p-4 grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
96
|
+
<div class="rn-filter-group">
|
|
97
|
+
<label>Compress (Gzip)</label>
|
|
98
|
+
<%= f.check_box :compress, class: "rn-input" %>
|
|
99
|
+
</div>
|
|
100
|
+
<div class="rn-filter-group">
|
|
101
|
+
<label>Encrypt (OpenSSL AES-256)</label>
|
|
102
|
+
<%= f.check_box :encrypt, class: "rn-input", data: { action: "change->rails_nexus#toggle" } %>
|
|
103
|
+
</div>
|
|
104
|
+
<div class="rn-filter-group" data-rails_nexus-target="encryptPassword" style="<%= 'display:none' unless @config.encrypt? %>">
|
|
105
|
+
<label>Encryption Password</label>
|
|
106
|
+
<%= f.password_field :encrypt_password, class: "rn-input", placeholder: "••••••••" %>
|
|
107
|
+
</div>
|
|
108
|
+
</div>
|
|
109
|
+
</div>
|
|
110
|
+
|
|
111
|
+
<%# ─── Remote Sync ────────────────────────────────────────── %>
|
|
112
|
+
<div class="rn-card">
|
|
113
|
+
<div class="p-4" style="border-bottom: 1px solid var(--rn-border-light)">
|
|
114
|
+
<h2 class="text-sm font-semibold" style="color: var(--rn-text)">Remote Sync (RSync)</h2>
|
|
115
|
+
</div>
|
|
116
|
+
<div class="p-4 grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
117
|
+
<div class="rn-filter-group">
|
|
118
|
+
<label>Enable RSync</label>
|
|
119
|
+
<%= f.check_box :rsync_enabled, class: "rn-input" %>
|
|
120
|
+
</div>
|
|
121
|
+
<div class="rn-filter-group">
|
|
122
|
+
<label>Host</label>
|
|
123
|
+
<%= f.text_field :rsync_host, class: "rn-input", placeholder: "192.168.1.100" %>
|
|
124
|
+
</div>
|
|
125
|
+
<div class="rn-filter-group">
|
|
126
|
+
<label>Port</label>
|
|
127
|
+
<%= f.number_field :rsync_port, class: "rn-input", value: @config.rsync_port || 22 %>
|
|
128
|
+
</div>
|
|
129
|
+
<div class="rn-filter-group">
|
|
130
|
+
<label>SSH User</label>
|
|
131
|
+
<%= f.text_field :rsync_user, class: "rn-input", placeholder: "backup" %>
|
|
132
|
+
</div>
|
|
133
|
+
<div class="rn-filter-group">
|
|
134
|
+
<label>Remote Path</label>
|
|
135
|
+
<%= f.text_field :rsync_path, class: "rn-input", placeholder: "/backups" %>
|
|
136
|
+
</div>
|
|
137
|
+
<div class="rn-filter-group">
|
|
138
|
+
<label>Mirror Mode</label>
|
|
139
|
+
<%= f.check_box :rsync_mirror, class: "rn-input" %>
|
|
140
|
+
</div>
|
|
141
|
+
</div>
|
|
142
|
+
</div>
|
|
143
|
+
|
|
144
|
+
<%# ─── Notifications ──────────────────────────────────────── %>
|
|
145
|
+
<div class="rn-card">
|
|
146
|
+
<div class="p-4" style="border-bottom: 1px solid var(--rn-border-light)">
|
|
147
|
+
<h2 class="text-sm font-semibold" style="color: var(--rn-text)">Notifications</h2>
|
|
148
|
+
</div>
|
|
149
|
+
<div class="p-4 grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
150
|
+
<div class="rn-filter-group md:col-span-2">
|
|
151
|
+
<label>Notify Command</label>
|
|
152
|
+
<%= f.text_field :notify_command, class: "rn-input", placeholder: "e.g. curl -X POST https://hooks.slack.com/..." %>
|
|
153
|
+
<p class="text-xs mt-1" style="color: var(--rn-text-muted)">Variables: {STATUS}, {MODEL}, {ERROR}, {TIME}</p>
|
|
154
|
+
</div>
|
|
155
|
+
<div class="rn-filter-group">
|
|
156
|
+
<label>Notify on Success</label>
|
|
157
|
+
<%= f.check_box :notify_on_success, class: "rn-input" %>
|
|
158
|
+
</div>
|
|
159
|
+
<div class="rn-filter-group">
|
|
160
|
+
<label>Notify on Failure</label>
|
|
161
|
+
<%= f.check_box :notify_on_failure, class: "rn-input" %>
|
|
162
|
+
</div>
|
|
163
|
+
</div>
|
|
164
|
+
</div>
|
|
165
|
+
|
|
166
|
+
<%# ─── Schedule ────────────────────────────────────────────── %>
|
|
167
|
+
<div class="rn-card">
|
|
168
|
+
<div class="p-4" style="border-bottom: 1px solid var(--rn-border-light)">
|
|
169
|
+
<h2 class="text-sm font-semibold" style="color: var(--rn-text)">Schedule</h2>
|
|
170
|
+
</div>
|
|
171
|
+
<div class="p-4">
|
|
172
|
+
<div class="rn-filter-group" style="max-width: 300px">
|
|
173
|
+
<label>Cron Expression</label>
|
|
174
|
+
<%= f.text_field :schedule_cron, class: "rn-input", placeholder: "e.g. 0 2 * * * (daily at 2am)" %>
|
|
175
|
+
<p class="text-xs mt-1" style="color: var(--rn-text-muted)">min hour day month wday — leave blank for manual only</p>
|
|
176
|
+
</div>
|
|
177
|
+
</div>
|
|
178
|
+
</div>
|
|
179
|
+
|
|
180
|
+
<%# ─── Submit ──────────────────────────────────────────────── %>
|
|
181
|
+
<div class="flex items-center gap-3">
|
|
182
|
+
<%= f.submit @config.persisted? ? "Update Config" : "Create Config", class: "rn-btn rn-btn-primary" %>
|
|
183
|
+
<%= link_to "Cancel", backup_path, class: "rn-btn rn-btn-ghost" %>
|
|
184
|
+
</div>
|
|
185
|
+
<% end %>
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
<% page_title "History: #{@config.name}" %>
|
|
2
|
+
|
|
3
|
+
<div class="space-y-6">
|
|
4
|
+
<nav class="rn-breadcrumbs" aria-label="Breadcrumb">
|
|
5
|
+
<a href="<%= root_path %>" class="rn-breadcrumb">Dashboard</a>
|
|
6
|
+
<span class="rn-breadcrumb-sep">/</span>
|
|
7
|
+
<a href="<%= backup_path %>" class="rn-breadcrumb">Backups</a>
|
|
8
|
+
<span class="rn-breadcrumb-sep">/</span>
|
|
9
|
+
<span class="rn-breadcrumb-current"><%= @config.name %> History</span>
|
|
10
|
+
</nav>
|
|
11
|
+
|
|
12
|
+
<div class="flex items-center justify-between">
|
|
13
|
+
<h1 class="text-2xl font-bold" style="color: var(--rn-text)">History: <%= @config.name %></h1>
|
|
14
|
+
<div class="flex gap-2">
|
|
15
|
+
<%= button_to "Run Backup", backup_trigger_path(@config),
|
|
16
|
+
method: :post,
|
|
17
|
+
class: "rn-btn rn-btn-primary",
|
|
18
|
+
data: { turbo_confirm: "Run #{@config.name} backup now?" } %>
|
|
19
|
+
<%= link_to "Edit Config", edit_backup_path(@config), class: "rn-btn rn-btn-ghost" %>
|
|
20
|
+
</div>
|
|
21
|
+
</div>
|
|
22
|
+
|
|
23
|
+
<div class="rn-card">
|
|
24
|
+
<% if @records.any? %>
|
|
25
|
+
<div class="rn-table-wrap">
|
|
26
|
+
<table class="rn-table">
|
|
27
|
+
<thead>
|
|
28
|
+
<tr>
|
|
29
|
+
<th>Status</th>
|
|
30
|
+
<th>File</th>
|
|
31
|
+
<th>Size</th>
|
|
32
|
+
<th>Duration</th>
|
|
33
|
+
<th>Triggered By</th>
|
|
34
|
+
<th>Time</th>
|
|
35
|
+
<th>Error</th>
|
|
36
|
+
</tr>
|
|
37
|
+
</thead>
|
|
38
|
+
<tbody>
|
|
39
|
+
<% @records.each do |record| %>
|
|
40
|
+
<tr>
|
|
41
|
+
<td>
|
|
42
|
+
<span class="rn-badge rn-badge-<%= record.status == 'success' ? 'success' : record.status == 'failed' ? 'danger' : 'warning' %>">
|
|
43
|
+
<%= record.status.capitalize %>
|
|
44
|
+
</span>
|
|
45
|
+
</td>
|
|
46
|
+
<td><span class="text-xs rn-mono" style="color: var(--rn-text-secondary)"><%= record.file_path ? File.basename(record.file_path) : "—" %></span></td>
|
|
47
|
+
<td><span class="text-xs" style="color: var(--rn-text-secondary)"><%= record.file_size ? number_to_human_size(record.file_size) : "—" %></span></td>
|
|
48
|
+
<td><span class="text-xs" style="color: var(--rn-text-secondary)"><%= record.duration ? "#{record.duration.round(1)}s" : "—" %></span></td>
|
|
49
|
+
<td><span class="text-xs" style="color: var(--rn-text-muted)"><%= record.triggered_by || "—" %></span></td>
|
|
50
|
+
<td><span class="text-xs" style="color: var(--rn-text-muted)"><%= record.started_at&.strftime("%b %d %H:%M") %></span></td>
|
|
51
|
+
<td>
|
|
52
|
+
<% if record.error_message.present? %>
|
|
53
|
+
<span class="text-xs" style="color: var(--rn-danger)" title="<%= record.error_message %>">⚠</span>
|
|
54
|
+
<% end %>
|
|
55
|
+
</td>
|
|
56
|
+
</tr>
|
|
57
|
+
<% end %>
|
|
58
|
+
</tbody>
|
|
59
|
+
</table>
|
|
60
|
+
</div>
|
|
61
|
+
<% else %>
|
|
62
|
+
<div class="p-8 text-center">
|
|
63
|
+
<p class="text-sm" style="color: var(--rn-text-muted)">No backups recorded for this config yet.</p>
|
|
64
|
+
</div>
|
|
65
|
+
<% end %>
|
|
66
|
+
</div>
|
|
67
|
+
</div>
|