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.
@@ -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
- attr_reader :dump_path
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
- @dump_path = RailsNexus.configuration.backup_dump_path || default_dump_path
13
+ def initialize(config)
14
+ @config = config
15
+ @record = nil
16
+ @errors = []
9
17
  end
10
18
 
11
- # Run a backup for a model and record it in the table
12
- def trigger_backup(model_name)
13
- record = RailsNexus::Backup.start!(model_name: model_name, triggered_by: "ui")
19
+ def run
20
+ @record = RailsNexus::Backup.start!(
21
+ model_name: @config.name,
22
+ triggered_by: "service"
23
+ )
14
24
 
15
25
  begin
16
- FileUtils.mkdir_p(@dump_path)
17
-
18
- timestamp = Time.current.strftime("%Y%m%d_%H%M%S")
19
- filename = "#{model_name}_#{timestamp}.sql"
20
- file_path = File.join(@dump_path, filename)
21
-
22
- # Detect database adapter and run appropriate dump
23
- result = run_dump(model_name, file_path)
24
-
25
- if result[:success]
26
- file_size = File.exist?(file_path) ? File.size(file_path) : 0
27
- record.succeed!(file_path: file_path, file_size: file_size)
28
- { success: true, record_id: record.id, file_path: file_path }
29
- else
30
- record.fail!(error_message: result[:error])
31
- # Clean up failed file
32
- File.delete(file_path) if File.exist?(file_path)
33
- { success: false, error: result[:error], record_id: record.id }
34
- end
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
- { success: false, error: e.message, record_id: record.id }
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
- # Get backup records from the table
42
- def backup_records(limit: 50)
43
- RailsNexus::Backup.order(started_at: :desc).limit(limit)
44
- end
45
-
46
- # Get summary stats from the table
47
- def summary
48
- recent = RailsNexus::Backup.where("started_at >= ?", 7.days.ago)
49
- {
50
- total: RailsNexus::Backup.count,
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
- { status: "healthy", message: "Last backup #{last.model_name} — #{last.duration_human}" }
77
+ raise "Unsupported adapter: #{@config.adapter}"
73
78
  end
74
79
  end
75
80
 
76
- # Cleanup old backup records and files
77
- def cleanup!(retention_days: nil)
78
- days = retention_days || RailsNexus.configuration.backup_alert_threshold_hours&.div(24) || 30
79
- old_records = RailsNexus::Backup.where("started_at < ?", days.days.ago)
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
- # Delete files first
82
- old_records.find_each do |record|
83
- File.delete(record.file_path) if record.file_path && File.exist?(record.file_path)
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
- count = old_records.delete_all
87
- { deleted: count }
92
+ raw_path
88
93
  end
89
94
 
90
- private
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
- def default_dump_path
93
- Rails.root.join("storage", "rails_nexus", "backups").to_s
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
- # Detect adapter and run appropriate dump command
97
- def run_dump(model_name, file_path)
98
- config = ActiveRecord::Base.connection_db_config.configuration_hash
99
- adapter = config[:adapter]
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
- case adapter
102
- when /sqlite/
103
- run_sqlite_dump(config, file_path)
104
- when /mysql/
105
- run_mysql_dump(config, file_path)
106
- when /postgresql/, /postgres/
107
- run_postgresql_dump(config, file_path)
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 run_sqlite_dump(config, file_path)
114
- db_path = config[:database]
115
- return { success: false, error: "No database path configured" } unless db_path
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
- # SQLite: .dump produces SQL output
118
- output = `sqlite3 "#{db_path}" .dump 2>&1`
119
- if $?.success?
120
- File.write(file_path, output)
121
- { success: true }
122
- else
123
- { success: false, error: "sqlite3 dump failed: #{output}" }
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
- def run_mysql_dump(config, file_path)
128
- cmd = [
129
- "mysqldump",
130
- "--user=#{config[:username] || 'root'}",
131
- config[:password] ? "--password=#{config[:password]}" : nil,
132
- "--host=#{config[:host] || 'localhost'}",
133
- "--port=#{config[:port] || 3306}",
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
- def run_postgresql_dump(config, file_path)
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
- "pg_dump",
149
- "--username=#{config[:username] || 'postgres'}",
150
- config[:host] ? "--host=#{config[:host]}" : nil,
151
- config[:port] ? "--port=#{config[:port]}" : nil,
152
- "--file=#{file_path}",
153
- config[:database]
154
- ].compact.join(" ")
155
-
156
- env = config[:password] ? { "PGPASSWORD" => config[:password].to_s } : {}
157
-
158
- output = IO.capture2e(env, cmd)
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
- def human_size(bytes)
167
- return "0 B" if bytes.zero?
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
- units = %w[B KB MB GB TB]
170
- exp = (Math.log(bytes) / Math.log(1024)).to_i
171
- exp = units.size - 1 if exp >= units.size
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
- "%.1f %s" % [bytes.to_f / (1024**exp), units[exp]]
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>