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.
- checksums.yaml +4 -4
- data/app/assets/javascripts/rails_nexus/controllers.js +103 -6
- data/app/assets/stylesheets/rails_nexus/application.css +418 -42
- data/app/controllers/rails_nexus/backup_controller.rb +27 -19
- data/app/helpers/rails_nexus/application_helper.rb +5 -0
- data/app/javascript/controllers/rails_nexus_controller.js +8 -2
- data/app/javascript/controllers/rails_nexus_sidebar_controller.js +84 -3
- data/app/jobs/rails_nexus/backup_job.rb +18 -0
- data/app/models/rails_nexus/backup.rb +11 -11
- data/app/models/rails_nexus/backup_config.rb +84 -16
- data/app/services/rails_nexus/backup_service.rb +521 -115
- data/app/views/layouts/rails_nexus/_sidebar_navigation.html.erb +113 -0
- data/app/views/layouts/rails_nexus/application.html.erb +132 -26
- data/app/views/rails_nexus/backup/_form.html.erb +186 -37
- data/app/views/rails_nexus/backup/history.html.erb +17 -2
- data/app/views/rails_nexus/backup/index.html.erb +29 -72
- data/app/views/rails_nexus/logged_exceptions/_exceptions.html.erb +24 -55
- data/app/views/rails_nexus/logged_exceptions/index.html.erb +24 -102
- data/db/seeds/backup_configs.rb +140 -0
- data/lib/generators/rails_nexus/templates/migration.rb +32 -6
- data/lib/rails_nexus/version.rb +1 -1
- data/lib/tasks/rails_nexus.rake +14 -4
- metadata +4 -1
|
@@ -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.
|
|
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.
|
|
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.
|
|
8
|
-
|
|
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 :
|
|
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 :
|
|
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!(
|
|
19
|
+
def self.start!(config_name:, triggered_by: "system")
|
|
20
20
|
create!(
|
|
21
|
-
|
|
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(
|
|
51
|
-
records =
|
|
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
|
-
|
|
59
|
-
results =
|
|
60
|
-
latest =
|
|
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
|
-
|
|
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(
|
|
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 :
|
|
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=(
|
|
29
|
-
|
|
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
|
-
|
|
33
|
-
|
|
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
|
|
37
|
-
|
|
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
|
|
41
|
-
|
|
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 =
|
|
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
|
-
#
|
|
115
|
+
# ─── Stats ─────────────────────────────────────────────────────
|
|
59
116
|
def recent_backups(limit: 10)
|
|
60
|
-
RailsNexus::Backup.where(
|
|
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(
|
|
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]
|