rails_nexus 2.0.6 → 2.1.0

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,16 +1,84 @@
1
1
  import { Controller } from "@hotwired/stimulus"
2
2
 
3
3
  export default class extends Controller {
4
- static targets = ["drawer", "overlay"]
4
+ static targets = ["drawer", "overlay", "sidebar", "toggleButton"]
5
+
6
+ connect() {
7
+ this.handleResize = this.syncButtonState.bind(this)
8
+ window.addEventListener("resize", this.handleResize)
9
+
10
+ if (this.desktop() && this.sidebarHidden()) {
11
+ this.sidebarTarget.classList.add("is-hidden")
12
+ }
13
+
14
+ this.syncButtonState()
15
+ }
16
+
17
+ disconnect() {
18
+ window.removeEventListener("resize", this.handleResize)
19
+ }
5
20
 
6
21
  toggle() {
7
- this.drawerTarget.classList.toggle("open")
8
- this.overlayTarget.classList.toggle("open")
22
+ if (this.desktop()) {
23
+ const hidden = this.sidebarTarget.classList.toggle("is-hidden")
24
+ this.storeSidebarState(hidden)
25
+ } else {
26
+ const opening = !this.drawerTarget.classList.contains("open")
27
+ this.drawerTarget.classList.toggle("open", opening)
28
+ this.overlayTarget.classList.toggle("open", opening)
29
+ }
30
+
31
+ this.syncButtonState()
9
32
  }
10
33
 
11
34
  close() {
12
35
  this.drawerTarget.classList.remove("open")
13
36
  this.overlayTarget.classList.remove("open")
37
+ this.syncButtonState()
38
+ }
39
+
40
+ desktop() {
41
+ return window.matchMedia("(min-width: 1024px)").matches
42
+ }
43
+
44
+ sidebarHidden() {
45
+ try {
46
+ return localStorage.getItem("rails_nexus-sidebar-hidden") === "true"
47
+ } catch (_error) {
48
+ return false
49
+ }
50
+ }
51
+
52
+ storeSidebarState(hidden) {
53
+ try {
54
+ localStorage.setItem("rails_nexus-sidebar-hidden", hidden.toString())
55
+ } catch (_error) {
56
+ // The toggle still works when storage is unavailable.
57
+ }
58
+ }
59
+
60
+ syncButtonState() {
61
+ if (!this.hasToggleButtonTarget) return
62
+
63
+ const expanded = this.desktop()
64
+ ? !this.sidebarTarget.classList.contains("is-hidden")
65
+ : this.drawerTarget.classList.contains("open")
66
+
67
+ this.toggleButtonTarget.setAttribute("aria-expanded", expanded.toString())
68
+ this.toggleButtonTarget.setAttribute("aria-label", expanded ? "Hide navigation" : "Show navigation")
69
+ this.toggleButtonTarget.title = expanded ? "Hide navigation" : "Show navigation"
70
+ }
71
+
72
+ closeOtherMenus(event) {
73
+ const currentMenu = event.currentTarget
74
+ if (!currentMenu.open) return
75
+
76
+ const navigation = currentMenu.closest(".rn-sidebar-nav")
77
+ if (!navigation) return
78
+
79
+ navigation.querySelectorAll("details.rn-nav-section[open]").forEach((menu) => {
80
+ if (menu !== currentMenu) menu.open = false
81
+ })
14
82
  }
15
83
 
16
84
  toggleShortcuts(event) {
@@ -0,0 +1,18 @@
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
@@ -4,7 +4,7 @@ module RailsNexus
4
4
  class Backup < BaseRecord
5
5
  self.table_name = "rails_nexus_backups"
6
6
 
7
- validates :model_name, presence: true
7
+ validates :config_name, presence: true
8
8
  validates :status, presence: true, inclusion: { in: %w[running success failed] }
9
9
  validates :started_at, presence: true
10
10
 
@@ -12,13 +12,13 @@ module RailsNexus
12
12
  scope :successful, -> { where(status: "success") }
13
13
  scope :failed, -> { where(status: "failed") }
14
14
  scope :running, -> { where(status: "running") }
15
- scope :by_model, ->(name) { where(model_name: name) }
15
+ scope :by_config, ->(name) { where(config_name: name) }
16
16
  scope :latest_first, -> { order(started_at: :desc) }
17
17
 
18
18
  # Start tracking a backup run
19
- def self.start!(model_name:, triggered_by: "system")
19
+ def self.start!(config_name:, triggered_by: "system")
20
20
  create!(
21
- model_name: model_name,
21
+ config_name: config_name,
22
22
  status: "running",
23
23
  triggered_by: triggered_by,
24
24
  started_at: Time.current
@@ -47,25 +47,25 @@ module RailsNexus
47
47
  end
48
48
 
49
49
  # Get success rate for a model over the last N days
50
- def self.success_rate(model_name:, days: 7)
51
- records = by_model(model_name).where("started_at >= ?", days.days.ago).where.not(status: "running")
50
+ def self.success_rate(config_name:, days: 7)
51
+ records = by_config(config_name).where("started_at >= ?", days.days.ago).where.not(status: "running")
52
52
  return 0 if records.empty?
53
53
  (records.successful.count.to_f / records.count * 100).round(1)
54
54
  end
55
55
 
56
56
  # Get backup health summary
57
57
  def self.health_summary(alert_threshold_hours: 24)
58
- models = RailsNexus.configuration.backup_models || []
59
- results = models.map do |model_name|
60
- latest = by_model(model_name).successful.latest_first.first
58
+ configs = RailsNexus::BackupConfig.pluck(:name)
59
+ results = configs.map do |config_name|
60
+ latest = by_config(config_name).successful.latest_first.first
61
61
  age_hours = latest ? ((Time.current - latest.started_at) / 3600).round(1) : nil
62
62
 
63
63
  {
64
- model_name: model_name,
64
+ config_name: config_name,
65
65
  last_backup: latest&.started_at,
66
66
  age_hours: age_hours,
67
67
  status: age_hours.nil? ? "unknown" : (age_hours < alert_threshold_hours ? "healthy" : "stale"),
68
- success_rate: success_rate(model_name: model_name),
68
+ success_rate: success_rate(config_name: config_name),
69
69
  last_file_size: latest&.file_size
70
70
  }
71
71
  end
@@ -13,6 +13,7 @@ module RailsNexus
13
13
  attribute :enabled, :boolean, default: true
14
14
  attribute :compress, :boolean, default: true
15
15
  attribute :encrypted, :boolean, default: false
16
+ attribute :encrypt_base64, :boolean, default: false
16
17
  attribute :rsync_enabled, :boolean, default: false
17
18
  attribute :rsync_mirror, :boolean, default: false
18
19
  attribute :notify_on_success, :boolean, default: true
@@ -113,11 +114,15 @@ module RailsNexus
113
114
 
114
115
  # ─── Stats ─────────────────────────────────────────────────────
115
116
  def recent_backups(limit: 10)
116
- RailsNexus::Backup.where(model_name: name).order(started_at: :desc).limit(limit)
117
+ RailsNexus::Backup.where(config_name: name).order(started_at: :desc).limit(limit)
118
+ end
119
+
120
+ def last_failure
121
+ RailsNexus::Backup.where(config_name: name, status: "failed").order(started_at: :desc).first
117
122
  end
118
123
 
119
124
  def stats
120
- backups = RailsNexus::Backup.where(model_name: name)
125
+ backups = RailsNexus::Backup.where(config_name: name)
121
126
  recent = backups.where("started_at >= ?", 7.days.ago)
122
127
  {
123
128
  total: backups.count,
@@ -20,28 +20,33 @@ module RailsNexus
20
20
 
21
21
  def run
22
22
  @record = RailsNexus::Backup.start!(
23
- model_name: @config.name,
23
+ config_name: @config.name,
24
24
  triggered_by: "service"
25
25
  )
26
26
 
27
27
  begin
28
+ @stage = "preparing storage"
28
29
  FileUtils.mkdir_p(@config.storage_path_expanded)
29
30
 
30
31
  # Step 1: Dump database
32
+ @stage = "database dump"
31
33
  dump_path = dump_database
32
34
  raise "Database dump failed" unless dump_path
33
35
 
34
36
  # Step 2: Create archive if enabled (tar additional files/dirs)
37
+ @stage = "archive creation"
35
38
  if @config.archive_enabled? && @config.archive_paths_list.any?
36
39
  dump_path = create_archive(dump_path)
37
40
  end
38
41
 
39
42
  # Step 3: Split into chunks if enabled
43
+ @stage = "file splitting"
40
44
  if @config.split_chunks? && File.size(dump_path) > 50_000_000 # 50MB
41
45
  dump_path = split_file(dump_path)
42
46
  end
43
47
 
44
48
  # Step 4: Compress
49
+ @stage = "compression"
45
50
  if @config.compress? && !@config.split_chunks?
46
51
  dump_path = compress(dump_path)
47
52
  elsif @config.bzip2_compress? && !@config.split_chunks?
@@ -49,6 +54,7 @@ module RailsNexus
49
54
  end
50
55
 
51
56
  # Step 5: Encrypt
57
+ @stage = "encryption"
52
58
  if @config.encrypted?
53
59
  dump_path = encrypt_openssl(dump_path)
54
60
  elsif @config.gpg_enabled?
@@ -56,26 +62,32 @@ module RailsNexus
56
62
  end
57
63
 
58
64
  # Step 6: Sync to S3
65
+ @stage = "S3 sync"
59
66
  sync_s3(dump_path) if @config.s3_enabled?
60
67
 
61
68
  # Step 7: Sync to remote
62
- rsync_result = sync_remote(dump_path) if @config.rsync_enabled?
69
+ @stage = "remote sync"
70
+ sync_remote(dump_path) if @config.rsync_enabled?
63
71
 
64
72
  # Step 8: Cleanup old backups
73
+ @stage = "retention cleanup"
65
74
  cleanup_old_backups
66
75
 
67
76
  # Step 9: Record success
77
+ @stage = "recording success"
68
78
  file_size = calculate_size(dump_path)
69
79
  @record.succeed!(file_path: dump_path, file_size: file_size)
70
80
 
71
81
  # Step 10: Notify
82
+ @stage = "success notification"
72
83
  notify_success
73
84
 
74
85
  { success: true, record: @record, file_path: dump_path }
75
86
  rescue StandardError => e
76
- @record&.fail!(error_message: e.message)
77
- notify_failure(e.message)
78
- { success: false, error: e.message, record: @record }
87
+ full_error = failure_message(e)
88
+ @record&.fail!(error_message: full_error)
89
+ notify_failure(full_error)
90
+ { success: false, error: full_error, record: @record }
79
91
  ensure
80
92
  cleanup_temp_files
81
93
  end
@@ -101,10 +113,11 @@ module RailsNexus
101
113
  # ─── MySQL ─────────────────────────────────────────────────────
102
114
  def dump_mysql
103
115
  raw_path = @config.dump_filepath.gsub(/\.(gz|bz2|enc)$/, "")
116
+ password = resolve_password(@config.password)
104
117
  cmd = ["mysqldump"]
105
- cmd << "--user=#{@config.username}" if @config.username.present?
106
- cmd << "--password=#{@config.password}" if @config.password.present?
107
- cmd << "--host=#{@config.host}" if @config.host.present?
118
+ cmd << "--user=#{resolve_env(@config.username)}" if @config.username.present?
119
+ cmd << "--password=#{password}" if password.present?
120
+ cmd << "--host=#{resolve_env(@config.host)}" if @config.host.present?
108
121
  cmd << "--port=#{@config.port}" if @config.port.present?
109
122
  cmd << "--result-file=#{raw_path}"
110
123
  cmd << "--single-transaction"
@@ -112,16 +125,22 @@ module RailsNexus
112
125
  cmd << "--routines"
113
126
  cmd << "--triggers"
114
127
  cmd << "--events"
128
+ # Additional MySQL options (e.g. --defaults-extra-file)
129
+ additional_options = if @config.respond_to?(:mysql_additional_options)
130
+ @config.mysql_additional_options
131
+ end
132
+ if additional_options.present?
133
+ additional_options.split(" ").each { |opt| cmd << opt }
134
+ end
115
135
 
116
136
  # Skip tables
117
137
  @config.skip_tables_list.each { |t| cmd << "--ignore-table=#{@config.database_name}.#{t}" }
118
138
 
119
139
  cmd << @config.database_name
120
140
 
121
- stdout, stderr, status = Open3.capture3(cmd.join(" "))
141
+ stdout, stderr, status = capture_command(cmd.join(" "))
122
142
  unless status.success?
123
- @errors << "mysqldump failed: #{stderr}"
124
- return nil
143
+ raise "mysqldump failed (exit #{status.exitstatus}):\n#{stderr}\n#{stdout}"
125
144
  end
126
145
  register_temp_file(raw_path)
127
146
  raw_path
@@ -130,9 +149,10 @@ module RailsNexus
130
149
  # ─── PostgreSQL ────────────────────────────────────────────────
131
150
  def dump_postgresql
132
151
  raw_path = @config.dump_filepath.gsub(/\.(gz|bz2|enc)$/, "")
152
+ password = resolve_password(@config.password)
133
153
  cmd = ["pg_dump"]
134
- cmd << "--username=#{@config.username}" if @config.username.present?
135
- cmd << "--host=#{@config.host}" if @config.host.present?
154
+ cmd << "--username=#{resolve_env(@config.username)}" if @config.username.present?
155
+ cmd << "--host=#{resolve_env(@config.host)}" if @config.host.present?
136
156
  cmd << "--port=#{@config.port}" if @config.port.present?
137
157
  cmd << "--file=#{raw_path}"
138
158
  cmd << "--format=plain"
@@ -145,31 +165,32 @@ module RailsNexus
145
165
  cmd << @config.database_name
146
166
 
147
167
  env = {}
148
- env["PGPASSWORD"] = @config.password if @config.password.present?
168
+ env["PGPASSWORD"] = password if password.present?
149
169
 
150
170
  stdout, stderr, status = Open3.capture3(env, cmd.join(" "))
151
171
  unless status.success?
152
- @errors << "pg_dump failed: #{stderr}"
153
- return nil
172
+ raise "pg_dump failed (exit #{status.exitstatus}):\n#{stderr}\n#{stdout}"
154
173
  end
155
174
  register_temp_file(raw_path)
156
175
  raw_path
157
176
  end
158
177
 
178
+ def capture_command(*command)
179
+ Open3.capture3(*command)
180
+ end
181
+
159
182
  # ─── SQLite ────────────────────────────────────────────────────
160
183
  def dump_sqlite
161
184
  raw_path = @config.dump_filepath.gsub(/\.(gz|bz2|enc)$/, "")
162
185
  db_path = @config.database_name
163
186
 
164
187
  unless File.exist?(db_path)
165
- @errors << "SQLite database not found: #{db_path}"
166
- return nil
188
+ raise "SQLite database not found: #{db_path}"
167
189
  end
168
190
 
169
191
  stdout, stderr, status = Open3.capture3("sqlite3 \"#{db_path}\" .dump")
170
192
  unless status.success?
171
- @errors << "sqlite3 dump failed: #{stderr}"
172
- return nil
193
+ raise "sqlite3 dump failed (exit #{status.exitstatus}):\n#{stderr}\n#{stdout}"
173
194
  end
174
195
 
175
196
  File.write(raw_path, stdout)
@@ -183,13 +204,14 @@ module RailsNexus
183
204
  dir_path = raw_path.gsub(/\.sql$/, "")
184
205
  FileUtils.mkdir_p(dir_path)
185
206
 
207
+ password = resolve_password(@config.password)
186
208
  cmd = ["mongodump"]
187
- cmd << "--host=#{@config.host || 'localhost'}"
209
+ cmd << "--host=#{resolve_env(@config.host) || 'localhost'}"
188
210
  cmd << "--port=#{@config.port || 27017}"
189
211
  cmd << "--db=#{@config.database_name}"
190
212
  cmd << "--out=#{dir_path}"
191
- cmd << "--username=#{@config.username}" if @config.username.present?
192
- cmd << "--password=#{@config.password}" if @config.password.present?
213
+ cmd << "--username=#{resolve_env(@config.username)}" if @config.username.present?
214
+ cmd << "--password=#{password}" if password.present?
193
215
  cmd << "--authenticationDatabase=admin" if @config.username.present?
194
216
 
195
217
  # Skip collections
@@ -197,17 +219,15 @@ module RailsNexus
197
219
 
198
220
  stdout, stderr, status = Open3.capture3(cmd.join(" "))
199
221
  unless status.success?
200
- @errors << "mongodump failed: #{stderr}"
201
222
  FileUtils.rm_rf(dir_path) if Dir.exist?(dir_path)
202
- return nil
223
+ raise "mongodump failed (exit #{status.exitstatus}):\n#{stderr}\n#{stdout}"
203
224
  end
204
225
 
205
226
  # Tar the dump directory
206
227
  tar_path = "#{dir_path}.tar"
207
228
  stdout, stderr, status = Open3.capture3("tar -cf \"#{tar_path}\" -C \"#{File.dirname(dir_path)}\" \"#{File.basename(dir_path)}\"")
208
229
  unless status.success?
209
- @errors << "tar failed: #{stderr}"
210
- return nil
230
+ raise "tar failed (exit #{status.exitstatus}):\n#{stderr}\n#{stdout}"
211
231
  end
212
232
 
213
233
  FileUtils.rm_rf(dir_path)
@@ -222,7 +242,7 @@ module RailsNexus
222
242
 
223
243
  host = @config.host || "localhost"
224
244
  port = @config.port || 6379
225
- password = @config.password
245
+ password = resolve_password(@config.password)
226
246
 
227
247
  # Trigger BGSAVE first
228
248
  cmd = "redis-cli -h #{host} -p #{port}"
@@ -353,11 +373,16 @@ module RailsNexus
353
373
 
354
374
  def encrypt_openssl(file_path)
355
375
  enc_path = "#{file_path}.enc"
356
- password = @config.encryption_password
376
+ password = resolve_secret(@config.encryption_password)
357
377
 
358
- cmd = "openssl aes-256-cbc -salt -pbkdf2 -iter 100000 " \
359
- "-in \"#{file_path}\" -out \"#{enc_path}\" " \
360
- "-pass pass:#{password}"
378
+ # Best practice: AES-256-CBC + PBKDF2 + 600K iterations + random salt
379
+ # (AES-GCM not available in all OpenSSL builds)
380
+ cmd = "openssl enc -aes-256-cbc"
381
+ cmd += " -pbkdf2 -iter 600000"
382
+ cmd += " -salt"
383
+ cmd += " -in \"#{file_path}\""
384
+ cmd += " -out \"#{enc_path}\""
385
+ cmd += " -pass pass:#{password}"
361
386
 
362
387
  stdout, stderr, status = Open3.capture3(cmd)
363
388
  unless status.success?
@@ -371,10 +396,13 @@ module RailsNexus
371
396
 
372
397
  def encrypt_gpg(file_path)
373
398
  enc_path = "#{file_path}.gpg"
374
- password = @config.gpg_password
399
+ password = resolve_secret(@config.gpg_password)
375
400
 
376
- cmd = "echo #{password} | gpg --batch --yes --symmetric " \
401
+ # Best practice: AES256 with s2k-mode=iter for key derivation
402
+ cmd = "echo #{password.shellescape} | gpg --batch --yes --symmetric " \
377
403
  "--cipher-algo AES256 " \
404
+ "--s2k-mode 3 " \
405
+ "--s2k-count 65011712 " \
378
406
  "--passphrase-fd 0 " \
379
407
  "-o \"#{enc_path}\" \"#{file_path}\""
380
408
 
@@ -419,16 +447,37 @@ module RailsNexus
419
447
 
420
448
  remote = "#{@config.rsync_user}@#{@config.rsync_host}:#{@config.rsync_path}/"
421
449
 
422
- cmd = [
423
- "rsync",
424
- "-avz",
425
- "--progress",
426
- "-e", "ssh -p #{@config.rsync_port}",
427
- "\"#{file_path}\"",
428
- remote
429
- ].join(" ")
450
+ # Build rsync command
451
+ cmd = ["rsync"]
452
+ cmd << "-a"
453
+ cmd << "-z" # compress
454
+ cmd << "--progress"
455
+ cmd << "--archive" if @config.respond_to?(:rsync_archive?) && @config.rsync_archive?
456
+ cmd << "--delete" if @config.respond_to?(:rsync_mirror?) && @config.rsync_mirror?
457
+ cmd << "-e"
458
+ cmd << "ssh -p #{@config.rsync_port.presence || 22}"
459
+
460
+ # Add the dump file
461
+ cmd << file_path
462
+
463
+ # Add additional directories if configured
464
+ if @config.respond_to?(:rsync_directories) && @config.rsync_directories.present?
465
+ @config.rsync_directories.split(",").map(&:strip).reject(&:blank?).each do |dir|
466
+ expanded = dir.gsub("~", Dir.home)
467
+ cmd << expanded
468
+ end
469
+ end
430
470
 
431
- stdout, stderr, status = Open3.capture3(cmd)
471
+ # Add excludes
472
+ if @config.respond_to?(:rsync_excludes) && @config.rsync_excludes.present?
473
+ @config.rsync_excludes.split(",").map(&:strip).reject(&:blank?).each do |excl|
474
+ cmd << "--exclude=#{excl}"
475
+ end
476
+ end
477
+
478
+ cmd << remote
479
+
480
+ stdout, stderr, status = capture_command(*cmd)
432
481
  unless status.success?
433
482
  @errors << "RSync failed: #{stderr}"
434
483
  end
@@ -588,8 +637,46 @@ module RailsNexus
588
637
  @temp_files << path unless @temp_files.include?(path)
589
638
  end
590
639
 
640
+ # ─── Password Resolution ──────────────────────────────────────
641
+ # Supports:
642
+ # "plain_text" → used as-is
643
+ # "ENV[MY_VAR]" → resolved from ENV
644
+ # "${MY_VAR}" → resolved from ENV
645
+ # "credentials[:key]" → from Rails encrypted credentials
646
+ # "credentials[:a][:b]" → nested credentials key
647
+ def resolve_password(value)
648
+ return nil if value.blank?
649
+ resolve_secret(value)
650
+ end
651
+
652
+ def resolve_env(value)
653
+ resolve_secret(value)
654
+ end
655
+
656
+ def resolve_secret(value)
657
+ return nil if value.blank?
658
+ case value
659
+ when /^ENV\[(.+?)\]$/
660
+ ENV[$1]
661
+ when /^\$\{(.+?)\}$/
662
+ ENV[$1]
663
+ when /^credentials\[/
664
+ # credentials[:key] or credentials[:key][:subkey]
665
+ keys = value.scan(/\[:([^\]]+)\]/).flatten.map(&:to_sym)
666
+ Rails.application.credentials.dig(*keys)
667
+ else
668
+ value
669
+ end
670
+ end
671
+
591
672
  def command_exists?(cmd)
592
673
  system("which #{cmd} > /dev/null 2>&1")
593
674
  end
675
+
676
+ def failure_message(exception)
677
+ heading = "#{@stage || 'backup'} failed: #{exception.class}: #{exception.message}"
678
+ trace = Array(exception.backtrace).first(5).join("\n")
679
+ [heading, trace.presence, @errors.join("\n").presence].compact.join("\n\n")
680
+ end
594
681
  end
595
682
  end
@@ -1,6 +1,13 @@
1
1
  <nav class="rn-sidebar-nav" aria-label="RailsNexus navigation">
2
- <div class="rn-nav-section">
3
- <span class="rn-nav-section-label">Monitor</span>
2
+ <details class="rn-nav-section" data-action="toggle->rails_nexus-sidebar#closeOtherMenus" <%= "open" if monitor_menu_active %>>
3
+ <summary class="rn-nav-section-toggle">
4
+ <span class="rn-nav-section-title">
5
+ <span class="rn-nav-section-icon"><svg width="15" height="15" fill="none" viewBox="0 0 24 24" stroke-width="1.75" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M3 12h3l2.25-6 4.5 12 2.25-6H21" /></svg></span>
6
+ <span>Monitor</span>
7
+ </span>
8
+ <svg class="rn-nav-section-chevron" width="14" height="14" fill="none" viewBox="0 0 24 24" stroke-width="1.75" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" /></svg>
9
+ </summary>
10
+ <div class="rn-nav-section-items">
4
11
 
5
12
  <%= link_to root_path,
6
13
  class: "rn-nav-item #{request.path == rails_nexus.root_path ? 'active' : ''}" do %>
@@ -34,10 +41,18 @@
34
41
  <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="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z" /></svg>
35
42
  Statistics
36
43
  <% end %>
37
- </div>
44
+ </div>
45
+ </details>
38
46
 
39
- <div class="rn-nav-section">
40
- <span class="rn-nav-section-label">Operations</span>
47
+ <details class="rn-nav-section" data-action="toggle->rails_nexus-sidebar#closeOtherMenus" <%= "open" if operations_menu_active %>>
48
+ <summary class="rn-nav-section-toggle">
49
+ <span class="rn-nav-section-title">
50
+ <span class="rn-nav-section-icon"><svg width="15" height="15" fill="none" viewBox="0 0 24 24" stroke-width="1.75" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M4.5 6.75h15M7.5 12h9m-6 5.25h3" /></svg></span>
51
+ <span>Operations</span>
52
+ </span>
53
+ <svg class="rn-nav-section-chevron" width="14" height="14" fill="none" viewBox="0 0 24 24" stroke-width="1.75" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" /></svg>
54
+ </summary>
55
+ <div class="rn-nav-section-items">
41
56
 
42
57
  <%= link_to cron_jobs_path,
43
58
  class: "rn-nav-item #{request.path.start_with?(rails_nexus.cron_jobs_path) ? 'active' : ''}" do %>
@@ -46,20 +61,34 @@
46
61
  <% end %>
47
62
 
48
63
  <%= link_to backup_path,
49
- class: "rn-nav-item #{request.path.start_with?(rails_nexus.backup_path) ? 'active' : ''}" do %>
64
+ class: "rn-nav-item #{controller_path == 'rails_nexus/backup' && !%w[all_history history].include?(action_name) ? 'active' : ''}" do %>
50
65
  <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="M20.25 6.375c0 2.278-3.694 4.125-8.25 4.125S3.75 8.653 3.75 6.375m16.5 0c0-2.278-3.694-4.125-8.25-4.125S3.75 4.097 3.75 6.375m16.5 0v11.25c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125V6.375m16.5 0v3.75m-16.5-3.75v3.75m16.5 0v3.75C20.25 16.153 16.556 18 12 18s-8.25-1.847-8.25-4.125v-3.75m16.5 0c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125" /></svg>
51
66
  Backups
52
67
  <% end %>
53
68
 
69
+ <%= link_to backup_history_path,
70
+ class: "rn-nav-item #{controller_path == 'rails_nexus/backup' && %w[all_history history].include?(action_name) ? 'active' : ''}" do %>
71
+ <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 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
72
+ Backup History
73
+ <% end %>
74
+
54
75
  <%= link_to settings_path,
55
76
  class: "rn-nav-item #{request.path == rails_nexus.settings_path ? 'active' : ''}" do %>
56
77
  <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="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z" /><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /></svg>
57
78
  Settings
58
79
  <% end %>
59
- </div>
80
+ </div>
81
+ </details>
60
82
 
61
- <div class="rn-nav-section">
62
- <span class="rn-nav-section-label">Resources</span>
83
+ <details class="rn-nav-section" data-action="toggle->rails_nexus-sidebar#closeOtherMenus" <%= "open" if resources_menu_active %>>
84
+ <summary class="rn-nav-section-toggle">
85
+ <span class="rn-nav-section-title">
86
+ <span class="rn-nav-section-icon"><svg width="15" height="15" fill="none" viewBox="0 0 24 24" stroke-width="1.75" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M10.5 6H6.75A3.75 3.75 0 003 9.75v7.5A3.75 3.75 0 006.75 21h7.5A3.75 3.75 0 0018 17.25V13.5m-6-1.5L21 3m0 0h-5.25M21 3v5.25" /></svg></span>
87
+ <span>Resources</span>
88
+ </span>
89
+ <svg class="rn-nav-section-chevron" width="14" height="14" fill="none" viewBox="0 0 24 24" stroke-width="1.75" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" /></svg>
90
+ </summary>
91
+ <div class="rn-nav-section-items">
63
92
 
64
93
  <% if main_app.respond_to?(:root_path) %>
65
94
  <%= link_to main_app.root_path, class: "rn-nav-item rn-nav-item--muted" do %>
@@ -79,5 +108,6 @@
79
108
  <%= link[:label] %>
80
109
  <% end %>
81
110
  <% end %>
82
- </div>
111
+ </div>
112
+ </details>
83
113
  </nav>