rails_nexus 2.0.3 → 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.
@@ -274,12 +274,14 @@ export default class extends Controller {
274
274
  this.closeShortcuts()
275
275
  } else {
276
276
  this.shortcutsDialogTarget.classList.add("open")
277
+ document.getElementById("rn-shortcuts-button")?.setAttribute("aria-expanded", "true")
277
278
  }
278
279
  }
279
280
 
280
281
  closeShortcuts() {
281
282
  if (this.hasShortcutsDialogTarget) {
282
283
  this.shortcutsDialogTarget.classList.remove("open")
284
+ document.getElementById("rn-shortcuts-button")?.setAttribute("aria-expanded", "false")
283
285
  }
284
286
  }
285
287
 
@@ -371,10 +373,14 @@ export default class extends Controller {
371
373
  // ─── Loading State ────────────────────────────────────────
372
374
 
373
375
  showActivity() {
374
- this.activityTarget?.classList.remove("hidden")
376
+ if (!this.hasActivityTarget) return
377
+ this.activityTarget.hidden = false
378
+ this.activityTarget.classList.remove("hidden")
375
379
  }
376
380
 
377
381
  hideActivity() {
378
- this.activityTarget?.classList.add("hidden")
382
+ if (!this.hasActivityTarget) return
383
+ this.activityTarget.hidden = true
384
+ this.activityTarget.classList.add("hidden")
379
385
  }
380
386
  }
@@ -1,15 +1,96 @@
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
+ })
82
+ }
83
+
84
+ toggleShortcuts(event) {
85
+ const dialog = document.querySelector("[data-rails_nexus-target='shortcutsDialog']")
86
+ if (!dialog) return
87
+
88
+ const isOpening = !dialog.classList.contains("open")
89
+ dialog.classList.toggle("open", isOpening)
90
+ event.currentTarget.setAttribute("aria-expanded", isOpening.toString())
91
+
92
+ if (isOpening) {
93
+ dialog.querySelector("button")?.focus()
94
+ }
14
95
  }
15
96
  }
@@ -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
@@ -4,50 +4,107 @@ module RailsNexus
4
4
  class BackupConfig < BaseRecord
5
5
  self.table_name = "rails_nexus_backup_configs"
6
6
 
7
- ADAPTERS = %w[mysql postgresql sqlite].freeze
7
+ ADAPTERS = %w[mysql postgresql sqlite mongodb redis].freeze
8
+
9
+ # ─── Boolean defaults ──────────────────────────────────────────
10
+ # Rails doesn't apply DB defaults to new in-memory records, so
11
+ # check_box helpers in form_for render unchecked. These declarations
12
+ # ensure the form reflects the intended defaults.
13
+ attribute :enabled, :boolean, default: true
14
+ attribute :compress, :boolean, default: true
15
+ attribute :encrypted, :boolean, default: false
16
+ attribute :encrypt_base64, :boolean, default: false
17
+ attribute :rsync_enabled, :boolean, default: false
18
+ attribute :rsync_mirror, :boolean, default: false
19
+ attribute :notify_on_success, :boolean, default: true
20
+ attribute :notify_on_failure, :boolean, default: true
21
+ attribute :s3_enabled, :boolean, default: false
22
+ attribute :gpg_enabled, :boolean, default: false
23
+ attribute :email_notify, :boolean, default: false
24
+ attribute :archive_enabled, :boolean, default: false
25
+ attribute :bzip2_compress, :boolean, default: false
26
+ attribute :split_chunks, :boolean, default: false
8
27
 
9
28
  validates :name, presence: true, uniqueness: true
10
- validates :database_name, presence: true
29
+ validates :database_name, presence: true, if: -> { adapter != "redis" }
11
30
  validates :adapter, presence: true, inclusion: { in: ADAPTERS }
12
31
  validates :storage_path, presence: true
13
32
  validates :keep_count, numericality: { greater_than: 0 }
14
- validates :encrypt_password, presence: true, if: :encrypt?
33
+ validates :encryption_password, presence: true, if: :encrypted?
15
34
  validates :rsync_host, presence: true, if: :rsync_enabled?
16
35
  validates :rsync_user, presence: true, if: :rsync_enabled?
36
+ validates :s3_bucket, presence: true, if: :s3_enabled?
37
+ validates :s3_access_key, presence: true, if: :s3_enabled?
38
+ validates :s3_secret_key, presence: true, if: :s3_enabled?
39
+ validates :gpg_password, presence: true, if: :gpg_enabled?
40
+ validates :email_to, presence: true, if: :email_notify?
17
41
 
18
42
  scope :enabled, -> { where(enabled: true) }
19
43
  scope :disabled, -> { where(enabled: false) }
20
44
 
21
45
  serialize :skip_tables, coder: JSON
46
+ serialize :archive_paths, coder: JSON
47
+ serialize :archive_excludes, coder: JSON
22
48
 
49
+ # ─── Skip Tables ───────────────────────────────────────────────
23
50
  def skip_tables_list
24
51
  return [] if skip_tables.blank?
25
52
  skip_tables.is_a?(Array) ? skip_tables : JSON.parse(skip_tables.to_s) rescue []
26
53
  end
27
54
 
28
- def skip_tables_list=(list)
29
- self.skip_tables = list
55
+ def skip_tables_list=(value)
56
+ return if value.blank?
57
+ self.skip_tables = if value.is_a?(String)
58
+ value.split(",").map(&:strip).reject(&:blank?)
59
+ else
60
+ value
61
+ end
30
62
  end
31
63
 
32
- def mysql?
33
- adapter == "mysql"
64
+ # ─── Archive Paths ─────────────────────────────────────────────
65
+ def archive_paths_list
66
+ return [] if archive_paths.blank?
67
+ archive_paths.is_a?(Array) ? archive_paths : JSON.parse(archive_paths.to_s) rescue []
34
68
  end
35
69
 
36
- def postgresql?
37
- adapter == "postgresql"
70
+ def archive_paths_list=(value)
71
+ return if value.blank?
72
+ self.archive_paths = if value.is_a?(String)
73
+ value.split(",").map(&:strip).reject(&:blank?)
74
+ else
75
+ value
76
+ end
38
77
  end
39
78
 
40
- def sqlite?
41
- adapter == "sqlite"
79
+ def archive_excludes_list
80
+ return [] if archive_excludes.blank?
81
+ archive_excludes.is_a?(Array) ? archive_excludes : JSON.parse(archive_excludes.to_s) rescue []
42
82
  end
43
83
 
84
+ def archive_excludes_list=(value)
85
+ return if value.blank?
86
+ self.archive_excludes = if value.is_a?(String)
87
+ value.split(",").map(&:strip).reject(&:blank?)
88
+ else
89
+ value
90
+ end
91
+ end
92
+
93
+ # ─── Adapter checks ────────────────────────────────────────────
94
+ def mysql?; adapter == "mysql"; end
95
+ def postgresql?; adapter == "postgresql"; end
96
+ def sqlite?; adapter == "sqlite"; end
97
+ def mongodb?; adapter == "mongodb"; end
98
+ def redis?; adapter == "redis"; end
99
+
100
+ # ─── Path helpers ──────────────────────────────────────────────
44
101
  def storage_path_expanded
45
102
  storage_path.gsub("~", Dir.home)
46
103
  end
47
104
 
48
105
  def dump_filename
49
106
  timestamp = Time.current.strftime("%Y%m%d_%H%M%S")
50
- ext = compress? ? "sql.gz" : "sql"
107
+ ext = dump_extension
51
108
  "#{name}_#{timestamp}.#{ext}"
52
109
  end
53
110
 
@@ -55,14 +112,17 @@ module RailsNexus
55
112
  File.join(storage_path_expanded, dump_filename)
56
113
  end
57
114
 
58
- # Get recent backup records for this config
115
+ # ─── Stats ─────────────────────────────────────────────────────
59
116
  def recent_backups(limit: 10)
60
- 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
61
122
  end
62
123
 
63
- # Summary stats
64
124
  def stats
65
- backups = RailsNexus::Backup.where(model_name: name)
125
+ backups = RailsNexus::Backup.where(config_name: name)
66
126
  recent = backups.where("started_at >= ?", 7.days.ago)
67
127
  {
68
128
  total: backups.count,
@@ -79,6 +139,14 @@ module RailsNexus
79
139
 
80
140
  private
81
141
 
142
+ def dump_extension
143
+ parts = ["sql"]
144
+ parts << "gz" if compress? && !bzip2_compress?
145
+ parts << "bz2" if bzip2_compress?
146
+ parts << "enc" if encrypted?
147
+ parts.join(".")
148
+ end
149
+
82
150
  def human_size(bytes)
83
151
  return "0 B" if bytes.zero?
84
152
  units = %w[B KB MB GB TB]