rails_nexus 2.0.0 → 2.0.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 33e67e4702cc5b31061fc12c76dc25c647717f12fbecf14fa485a0b910c969ad
4
- data.tar.gz: d4f46f7b519cd0a8d99212f03aba8537b761fbce9ddbce081198d9c4d9204f44
3
+ metadata.gz: 4b3560dacbd2f1c044a47737102cf9cf3a12d158663d36842b901f8bcb83f375
4
+ data.tar.gz: f5301d5c1f68c608d8f856203c55d12416d528e489b1fd0acd596b4e2b2752bb
5
5
  SHA512:
6
- metadata.gz: d31501ea96c117ce8b2f86867760be1982e7af7ed89ed21cfad27681a9dc5cd0a1260bc907abf1288cf1b45bdd5613989c6b31a518a028d641500d54ca5db7dd
7
- data.tar.gz: 9cb09061f96d7d2478c5147867c90029859737b2dbfdd939721ec4c9c5a50103a0da94bb0e2e6d1ed4582186d2cc86e300ab5ccc9339755041870f3f3cbc9f37
6
+ metadata.gz: 1082abffeb01634510cce7be4760b28f595e45f55b4fc86b8eb981b804e16962ee67061d893f12041bfbde8e287d977e3f07814932365aec26b4039b0979ad75
7
+ data.tar.gz: e2e8691ea3e6463161f1ac3e7fd2d846a472d34278b6c3e133cabcb21cbeb0bc0ca8d03c8b082b7cceff3c4692b4adad0064d1353c2651066bff08616798e30d
@@ -2,90 +2,57 @@
2
2
 
3
3
  module RailsNexus
4
4
  class BackupController < ApplicationController
5
- before_action :verify_access
5
+ before_action :rails_nexus_require_auth!
6
6
  before_action :check_backup_enabled
7
7
 
8
8
  # GET /rails_nexus/backup
9
9
  def index
10
- @backup_service = BackupService.new
11
- @health = @backup_service.health_status
12
- @summary = @backup_service.summary
13
- @models = @backup_service.models
14
- @schedule = @backup_service.cron_schedule
10
+ @service = BackupService.new
11
+ @health = @service.health_status
12
+ @summary = @service.summary
13
+ @records = @service.backup_records
15
14
  end
16
15
 
17
16
  # GET /rails_nexus/backup/files
18
17
  def files
19
- @backup_service = BackupService.new
20
- @files = @backup_service.backup_files
18
+ @service = BackupService.new
19
+ @records = @service.backup_records(limit: 100)
21
20
  end
22
21
 
23
22
  # POST /rails_nexus/backup/trigger/:model
24
23
  def trigger
25
- @backup_service = BackupService.new
26
- result = @backup_service.trigger_backup(params[:model])
24
+ @service = BackupService.new
25
+ result = @service.trigger_backup(params[:model])
27
26
 
28
27
  if result[:success]
29
- redirect_to backup_path, notice: "Backup '#{params[:model]}' triggered (PID: #{result[:pid]})"
28
+ redirect_to backup_path, notice: "Backup '#{params[:model]}' completed successfully."
30
29
  else
31
- redirect_to backup_path, alert: "Failed: #{result[:error]}"
30
+ redirect_to backup_path, alert: "Backup failed: #{result[:error]}"
32
31
  end
33
32
  end
34
33
 
35
- # GET /rails_nexus/backup/health
36
- def health
37
- @backup_service = BackupService.new
38
- @health = @backup_service.health_status
39
- @summary = @backup_service.summary
40
- end
41
-
42
- # GET /rails_nexus/backup/settings
43
- def settings
44
- @backup_config = BackupService.load_config
34
+ # DELETE /rails_nexus/backup/:id
35
+ def destroy
36
+ record = RailsNexus::Backup.find(params[:id])
37
+ # Delete the file if it exists
38
+ File.delete(record.file_path) if record.file_path && File.exist?(record.file_path)
39
+ record.destroy
40
+ redirect_to backup_path, notice: "Backup record deleted."
45
41
  end
46
42
 
47
- # PATCH /rails_nexus/backup/settings
48
- def update_settings
49
- result = BackupService.save_config(backup_settings_params)
50
-
51
- if result[:success]
52
- redirect_to backup_settings_path, notice: "Backup settings saved successfully."
53
- else
54
- redirect_to backup_settings_path, alert: "Failed to save: #{result[:error]}"
55
- end
43
+ # GET /rails_nexus/backup/health
44
+ def health
45
+ @service = BackupService.new
46
+ @health = @service.health_status
47
+ @summary = @service.summary
56
48
  end
57
49
 
58
50
  private
59
51
 
60
- def verify_access
61
- config = RailsNexus.configuration
62
- return if config.auth_block.nil?
63
- unless config.auth_block&.call(self)
64
- render plain: "Forbidden", status: :forbidden
65
- end
66
- end
67
-
68
52
  def check_backup_enabled
69
53
  unless RailsNexus.configuration.backup_enabled
70
54
  render plain: "Backup management not enabled", status: :forbidden
71
55
  end
72
56
  end
73
-
74
- def backup_settings_params
75
- params.require(:backup_config).permit(
76
- :enabled,
77
- :config_path,
78
- :models_path,
79
- :dump_path,
80
- :notify_command,
81
- :alert_threshold_hours,
82
- :rsync_host,
83
- :rsync_port,
84
- :rsync_user,
85
- :rsync_path,
86
- :encrypt_password,
87
- :auto_cleanup_days
88
- )
89
- end
90
57
  end
91
58
  end
@@ -1,269 +1,170 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "yaml"
4
-
5
3
  module RailsNexus
6
4
  class BackupService
7
- attr_reader :config_path, :models_path, :dump_path
8
-
9
- CONFIG_FILE = "rails_nexus_backup.yml"
5
+ attr_reader :dump_path
10
6
 
11
7
  def initialize
12
- saved = self.class.load_config
13
- @config_path = saved[:config_path] || RailsNexus.configuration.backup_config_path || default_config_path
14
- @models_path = saved[:models_path] || RailsNexus.configuration.backup_models_path || default_models_path
15
- @dump_path = saved[:dump_path] || RailsNexus.configuration.backup_dump_path || default_dump_path
16
- end
17
-
18
- # Load saved config from YAML file
19
- def self.load_config
20
- path = config_file_path
21
- return default_config unless File.exist?(path)
22
-
23
- data = YAML.safe_load_file(path, permitted_classes: [Symbol, Date, Time]) || {}
24
- default_config.merge(data.transform_keys(&:to_sym))
25
- rescue StandardError
26
- default_config
8
+ @dump_path = RailsNexus.configuration.backup_dump_path || default_dump_path
27
9
  end
28
10
 
29
- # Save config to YAML file
30
- def self.save_config(params)
31
- config = load_config
32
-
33
- # Merge new params
34
- params.each do |key, value|
35
- config[key.to_sym] = value
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")
14
+
15
+ 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
35
+ rescue StandardError => e
36
+ record.fail!(error_message: e.message)
37
+ { success: false, error: e.message, record_id: record.id }
36
38
  end
37
-
38
- # Ensure directory exists
39
- dir = File.dirname(config_file_path)
40
- FileUtils.mkdir_p(dir) unless Dir.exist?(dir)
41
-
42
- # Write YAML
43
- File.write(config_file_path, config.to_yaml)
44
-
45
- { success: true }
46
- rescue StandardError => e
47
- { success: false, error: e.message }
48
- end
49
-
50
- # Delete saved config
51
- def self.reset_config
52
- File.delete(config_file_path) if File.exist?(config_file_path)
53
- { success: true }
54
- rescue StandardError => e
55
- { success: false, error: e.message }
56
39
  end
57
40
 
58
- # Check if backup gem is configured
59
- def configured?
60
- File.exist?(@config_path) && Dir.exist?(@models_path)
61
- end
62
-
63
- # Get all backup models
64
- def models
65
- return [] unless configured?
66
-
67
- Dir.glob(File.join(@models_path, "*.rb")).map do |path|
68
- parse_model_file(path)
69
- end.compact
41
+ # Get backup records from the table
42
+ def backup_records(limit: 50)
43
+ RailsNexus::Backup.order(started_at: :desc).limit(limit)
70
44
  end
71
45
 
72
- # Get backup files
73
- def backup_files
74
- return [] unless @dump_path && Dir.exist?(@dump_path)
75
-
76
- Dir.glob(File.join(@dump_path, "*")).select { |f| File.file?(f) }.map do |path|
77
- {
78
- name: File.basename(path),
79
- path: path,
80
- size: File.size(path),
81
- size_human: human_size(File.size(path)),
82
- created_at: File.mtime(path),
83
- age_hours: ((Time.current - File.mtime(path)) / 3600).round(1),
84
- encrypted: path.end_with?(".enc"),
85
- compressed: path.end_with?(".gz") || path.end_with?(".tar.gz")
86
- }
87
- end.sort_by { |f| -f[:created_at].to_i }
88
- end
89
-
90
- # Get backup summary stats
46
+ # Get summary stats from the table
91
47
  def summary
92
- files = backup_files
93
- {
94
- total_files: files.size,
95
- total_size: files.sum { |f| f[:size] },
96
- total_size_human: human_size(files.sum { |f| f[:size] }),
97
- last_backup: files.first&.dig(:created_at),
98
- last_backup_age_hours: files.first&.dig(:age_hours),
99
- oldest_backup: files.last&.dig(:created_at),
100
- models_count: models.size,
101
- healthy: last_backup_healthy?,
102
- alerts: generate_alerts
103
- }
104
- end
105
-
106
- # Get cron schedule from whenever config
107
- def cron_schedule
108
- schedule_file = File.join(File.dirname(@config_path), "config", "schedule.rb")
109
- return nil unless File.exist?(schedule_file)
110
-
111
- content = File.read(schedule_file)
48
+ recent = RailsNexus::Backup.where("started_at >= ?", 7.days.ago)
112
49
  {
113
- file: schedule_file,
114
- entries: parse_schedule(content),
115
- raw: content
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
116
59
  }
117
60
  end
118
61
 
119
- # Run a backup model
120
- def trigger_backup(model_name)
121
- return { success: false, error: "Backup not configured" } unless configured?
122
- return { success: false, error: "Invalid model: #{model_name}" } unless valid_model?(model_name)
123
-
124
- runner = find_runner_script
125
- return { success: false, error: "Runner script not found" } unless runner
126
-
127
- log_file = File.join(File.dirname(@config_path), "log", "cron.log")
128
- error_log = File.join(File.dirname(@config_path), "log", "cron-error.log")
129
-
130
- command = "bash #{runner} #{model_name} >> #{log_file} 2>> #{error_log}"
131
- pid = Process.spawn(command)
132
- Process.detach(pid)
133
-
134
- { success: true, pid: pid, model: model_name }
135
- end
136
-
137
- # Get backup health status
62
+ # Get health status based on table records
138
63
  def health_status
139
- return { status: "not_configured", message: "Backup not configured" } unless configured?
140
-
141
- summary_data = summary
142
- threshold = saved_config[:alert_threshold_hours]&.to_i || RailsNexus.configuration.backup_alert_threshold_hours || 24
64
+ last = RailsNexus::Backup.successful.latest_first.first
65
+ threshold = RailsNexus.configuration.backup_alert_threshold_hours || 24
143
66
 
144
- if summary_data[:last_backup].nil?
145
- { status: "warning", message: "No backups found", details: summary_data }
146
- elsif summary_data[:last_backup_age_hours] > threshold * 2
147
- { status: "critical", message: "Last backup #{summary_data[:last_backup_age_hours]}h ago (>#{threshold * 2}h)", details: summary_data }
148
- elsif summary_data[:last_backup_age_hours] > threshold
149
- { status: "warning", message: "Last backup #{summary_data[:last_backup_age_hours]}h ago (>#{threshold}h)", details: summary_data }
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" }
150
71
  else
151
- { status: "healthy", message: "Last backup #{summary_data[:last_backup_age_hours]}h ago", details: summary_data }
72
+ { status: "healthy", message: "Last backup #{last.model_name} — #{last.duration_human}" }
152
73
  end
153
74
  end
154
75
 
155
- # Get saved config
156
- def saved_config
157
- @saved_config ||= self.class.load_config
158
- end
159
-
160
- private
161
-
162
- def self.config_file_path
163
- Rails.root.join("config", CONFIG_FILE)
164
- end
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)
165
80
 
166
- def self.default_config
167
- {
168
- enabled: true,
169
- config_path: nil,
170
- models_path: nil,
171
- dump_path: nil,
172
- notify_command: nil,
173
- alert_threshold_hours: 24,
174
- rsync_host: nil,
175
- rsync_port: 22,
176
- rsync_user: nil,
177
- rsync_path: nil,
178
- encrypt_password: nil,
179
- auto_cleanup_days: 30
180
- }
181
- end
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)
84
+ end
182
85
 
183
- def default_config_path
184
- Rails.root.join("config", "backup", "config.rb").to_s
86
+ count = old_records.delete_all
87
+ { deleted: count }
185
88
  end
186
89
 
187
- def default_models_path
188
- Rails.root.join("config", "backup", "models").to_s
189
- end
90
+ private
190
91
 
191
92
  def default_dump_path
192
93
  Rails.root.join("storage", "rails_nexus", "backups").to_s
193
94
  end
194
95
 
195
- def parse_model_file(path)
196
- content = File.read(path)
197
- name_match = content.match(/Model\.new\(:(\w+)/)
198
- desc_match = content.match(/'([^']+)'/)
199
-
200
- return nil unless name_match
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]
201
100
 
202
- {
203
- name: name_match[1],
204
- description: desc_match ? desc_match[1] : name_match[1],
205
- file: path,
206
- has_mysql: content.include?("database MySQL"),
207
- has_local: content.include?("store_with Local"),
208
- has_rsync: content.include?("sync_with RSync"),
209
- has_encryption: content.include?("encrypt_with OpenSSL"),
210
- has_compression: content.include?("compress_with Gzip"),
211
- skip_tables: content.scan(/skip_tables\s*=\s*\[([^\]]+)\]/).flatten.first,
212
- keep_count: content.scan(/keep\s*=\s*(\d+)/).flatten.first&.to_i
213
- }
214
- end
215
-
216
- def parse_schedule(content)
217
- entries = []
218
- content.scan(/every\s+([^,]+)(?:,\s*at:\s*"([^"]+)")?\s+do\s+command\s+"([^"]+)"/) do |match|
219
- entries << {
220
- frequency: match[0].strip,
221
- time: match[1],
222
- command: match[2]
223
- }
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}" }
224
110
  end
225
- entries
226
111
  end
227
112
 
228
- def find_runner_script
229
- runner = File.join(File.dirname(@config_path), "bin", "run-backup")
230
- runner if File.exist?(runner)
231
- end
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
232
116
 
233
- def valid_model?(name)
234
- models.any? { |m| m[:name] == name }
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}" }
124
+ end
235
125
  end
236
126
 
237
- def last_backup_healthy?
238
- files = backup_files
239
- return false if files.empty?
240
-
241
- threshold = saved_config[:alert_threshold_hours]&.to_i || RailsNexus.configuration.backup_alert_threshold_hours || 24
242
- files.first[:age_hours] <= threshold
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}" }
143
+ end
243
144
  end
244
145
 
245
- def generate_alerts
246
- alerts = []
247
- files = backup_files
248
- threshold = saved_config[:alert_threshold_hours]&.to_i || RailsNexus.configuration.backup_alert_threshold_hours || 24
146
+ def run_postgresql_dump(config, file_path)
147
+ 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(" ")
249
155
 
250
- if files.empty?
251
- alerts << { level: "warning", message: "No backup files found" }
252
- elsif files.first[:age_hours] > threshold
253
- alerts << { level: "critical", message: "No backup in #{files.first[:age_hours]}h (threshold: #{threshold}h)" }
254
- end
156
+ env = config[:password] ? { "PGPASSWORD" => config[:password].to_s } : {}
255
157
 
256
- # Check for old backups consuming space
257
- old_files = files.select { |f| f[:age_hours] > 720 } # 30 days
258
- if old_files.size > 10
259
- alerts << { level: "info", message: "#{old_files.size} backups older than 30 days (#{human_size(old_files.sum { |f| f[:size] })})" }
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}" }
260
163
  end
261
-
262
- alerts
263
164
  end
264
165
 
265
166
  def human_size(bytes)
266
- return "0 B" if bytes == 0
167
+ return "0 B" if bytes.zero?
267
168
 
268
169
  units = %w[B KB MB GB TB]
269
170
  exp = (Math.log(bytes) / Math.log(1024)).to_i
@@ -82,6 +82,14 @@
82
82
  <%# ─── Tools section ──────────────────────────────────────── %>
83
83
  <div class="rn-sidebar-section-divider"></div>
84
84
 
85
+ <% if main_app.respond_to?(:root_path) %>
86
+ <%= link_to main_app.root_path,
87
+ class: "rn-nav-item rn-nav-item--muted" do %>
88
+ <svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" /></svg>
89
+ Admin Panel
90
+ <% end %>
91
+ <% end %>
92
+
85
93
  <%= link_to feed_logged_exceptions_path(format: :rss),
86
94
  class: "rn-nav-item rn-nav-item--muted" do %>
87
95
  <svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12.75 19.5v-.75a7.5 7.5 0 00-7.5-7.5H4.5m0-6.75h.75c7.87 0 14.25 6.38 14.25 14.25v.75M6 18.75a.375.375 0 11-.75 0 .375.375 0 01.75 0z" /></svg>
@@ -1,7 +1,6 @@
1
1
  <% page_title t(".title", default: "Backup Files") %>
2
2
 
3
3
  <div class="space-y-6">
4
- <%# ─── Header ──────────────────────────────────────────────── %>
5
4
  <nav class="rn-breadcrumbs" aria-label="Breadcrumb">
6
5
  <a href="<%= root_path %>" class="rn-breadcrumb">Dashboard</a>
7
6
  <span class="rn-breadcrumb-sep">/</span>
@@ -10,34 +9,49 @@
10
9
  <span class="rn-breadcrumb-current">Files</span>
11
10
  </nav>
12
11
 
13
- <h1 class="text-2xl font-bold" style="color: var(--rn-text)">Backup Files</h1>
14
-
15
- <%# ─── Files Table ─────────────────────────────────────────── %>
16
12
  <div class="rn-card">
17
- <% if @files.any? %>
13
+ <div class="p-4" style="border-bottom: 1px solid var(--rn-border-light)">
14
+ <h2 class="text-sm font-semibold" style="color: var(--rn-text)">All Backup Records</h2>
15
+ <p class="text-xs mt-1" style="color: var(--rn-text-muted)">Complete history of backup runs</p>
16
+ </div>
17
+
18
+ <% if @records.any? %>
18
19
  <div class="rn-table-wrap">
19
20
  <table class="rn-table">
20
21
  <thead>
21
22
  <tr>
23
+ <th>ID</th>
24
+ <th>Model</th>
25
+ <th>Status</th>
22
26
  <th>File</th>
23
27
  <th>Size</th>
24
- <th>Created</th>
25
- <th>Age</th>
26
- <th>Format</th>
28
+ <th>Duration</th>
29
+ <th>Triggered By</th>
30
+ <th>Started</th>
31
+ <th></th>
27
32
  </tr>
28
33
  </thead>
29
34
  <tbody>
30
- <% @files.each do |file| %>
35
+ <% @records.each do |record| %>
31
36
  <tr>
37
+ <td><span class="text-xs rn-mono" style="color: var(--rn-text-muted)">#<%= record.id %></span></td>
38
+ <td><span class="font-medium" style="color: var(--rn-text)"><%= record.model_name %></span></td>
39
+ <td>
40
+ <span class="rn-badge rn-badge-<%= record.status == 'success' ? 'success' : record.status == 'failed' ? 'danger' : 'warning' %>">
41
+ <%= record.status.capitalize %>
42
+ </span>
43
+ </td>
32
44
  <td>
33
- <span class="rn-mono text-xs" style="color: var(--rn-text)"><%= file[:name] %></span>
45
+ <span class="text-xs rn-mono" style="color: var(--rn-text-secondary)"><%= record.file_path ? File.basename(record.file_path) : "—" %></span>
34
46
  </td>
35
- <td><span class="text-sm" style="color: var(--rn-text-secondary)"><%= file[:size_human] %></span></td>
36
- <td><span class="text-xs" style="color: var(--rn-text-muted)"><%= file[:created_at].strftime("%b %d, %Y %I:%M %p") %></span></td>
37
- <td><span class="text-xs" style="color: var(--rn-text-muted)"><%= file[:age_hours] %>h</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>
38
51
  <td>
39
- <span class="rn-badge <%= file[:encrypted] ? 'rn-badge-success' : '' %>"><%= file[:encrypted] ? 'Encrypted' : 'Plain' %></span>
40
- <span class="rn-badge <%= file[:compressed] ? 'rn-badge-info' : '' %>"><%= file[:compressed] ? 'Gzipped' : 'Raw' %></span>
52
+ <% if record.error_message.present? %>
53
+ <span class="text-xs" style="color: var(--rn-danger)" title="<%= record.error_message %>">⚠</span>
54
+ <% end %>
41
55
  </td>
42
56
  </tr>
43
57
  <% end %>
@@ -46,7 +60,7 @@
46
60
  </div>
47
61
  <% else %>
48
62
  <div class="p-8 text-center">
49
- <p class="text-sm" style="color: var(--rn-text-muted)">No backup files found.</p>
63
+ <p class="text-sm" style="color: var(--rn-text-muted)">No backup records found.</p>
50
64
  </div>
51
65
  <% end %>
52
66
  </div>