backup_nexus 0.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.
Files changed (39) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +7 -0
  3. data/MIT-LICENSE +20 -0
  4. data/README.md +82 -0
  5. data/Rakefile +10 -0
  6. data/app/controllers/backup_nexus/application_controller.rb +31 -0
  7. data/app/controllers/backup_nexus/backup_controller.rb +119 -0
  8. data/app/helpers/backup_nexus/application_helper.rb +51 -0
  9. data/app/jobs/backup_nexus/backup_job.rb +18 -0
  10. data/app/models/backup_nexus/backup.rb +107 -0
  11. data/app/models/backup_nexus/backup_config.rb +189 -0
  12. data/app/services/backup_nexus/backup_runner.rb +62 -0
  13. data/app/services/backup_nexus/backup_service.rb +817 -0
  14. data/app/views/backup_nexus/backup/_form.html.erb +334 -0
  15. data/app/views/backup_nexus/backup/config_history.html.erb +67 -0
  16. data/app/views/backup_nexus/backup/edit.html.erb +15 -0
  17. data/app/views/backup_nexus/backup/history.html.erb +75 -0
  18. data/app/views/backup_nexus/backup/index.html.erb +112 -0
  19. data/app/views/backup_nexus/backup/new.html.erb +15 -0
  20. data/backup_nexus.gemspec +30 -0
  21. data/config/routes.rb +15 -0
  22. data/db/seeds/backup_configs_safe.rb +61 -0
  23. data/lib/backup_nexus/base_record.rb +7 -0
  24. data/lib/backup_nexus/configuration.rb +19 -0
  25. data/lib/backup_nexus/engine.rb +51 -0
  26. data/lib/backup_nexus/version.rb +3 -0
  27. data/lib/backup_nexus.rb +26 -0
  28. data/lib/generators/backup_nexus/backup_generator.rb +70 -0
  29. data/lib/generators/backup_nexus/install_generator.rb +27 -0
  30. data/lib/generators/backup_nexus/templates/backup/backup/backup_helper.sh +152 -0
  31. data/lib/generators/backup_nexus/templates/backup/backup/config.rb +27 -0
  32. data/lib/generators/backup_nexus/templates/backup/backup/models/daily_backup.rb +24 -0
  33. data/lib/generators/backup_nexus/templates/backup/backup/models/full_backup.rb +35 -0
  34. data/lib/generators/backup_nexus/templates/backup/backup/models/sync_backup.rb +43 -0
  35. data/lib/generators/backup_nexus/templates/backup/backup/mysql-config/db_config.cnf +15 -0
  36. data/lib/generators/backup_nexus/templates/backup/backup/schedule.rb +28 -0
  37. data/lib/generators/backup_nexus/templates/migration.rb +105 -0
  38. data/lib/tasks/backup_nexus.rake +81 -0
  39. metadata +138 -0
@@ -0,0 +1,334 @@
1
+ <%= form_for @config, url: (@config.persisted? ? backup_update_path(@config) : backup_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>&bull; <%= 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 style="display:flex;align-items:center;gap:8px;cursor:pointer">
31
+ <%= f.check_box :enabled %>
32
+ Enabled
33
+ </label>
34
+ </div>
35
+ </div>
36
+ </div>
37
+
38
+ <%# ─── Database ────────────────────────────────────────────── %>
39
+ <div class="rn-card">
40
+ <div class="p-4" style="border-bottom: 1px solid var(--rn-border-light)">
41
+ <h2 class="text-sm font-semibold" style="color: var(--rn-text)">Database</h2>
42
+ </div>
43
+ <div class="p-4 grid grid-cols-1 md:grid-cols-3 gap-4">
44
+ <div class="rn-filter-group">
45
+ <label>Adapter *</label>
46
+ <%= f.select :adapter, options_for_select([
47
+ ["MySQL", "mysql"],
48
+ ["PostgreSQL", "postgresql"],
49
+ ["SQLite", "sqlite"],
50
+ ["MongoDB", "mongodb"],
51
+ ["Redis", "redis"]
52
+ ], @config.adapter), {}, class: "rn-select" %>
53
+ </div>
54
+ <div class="rn-filter-group">
55
+ <label>Database Name *</label>
56
+ <%= f.text_field :database_name, class: "rn-input", placeholder: "e.g. myapp_production" %>
57
+ </div>
58
+ <div class="rn-filter-group">
59
+ <label>Host</label>
60
+ <%= f.text_field :host, class: "rn-input", placeholder: "localhost" %>
61
+ </div>
62
+ <div class="rn-filter-group">
63
+ <label>Port</label>
64
+ <%= f.number_field :port, class: "rn-input", placeholder: "3306" %>
65
+ </div>
66
+ <div class="rn-filter-group">
67
+ <label>Username</label>
68
+ <%= f.text_field :username, class: "rn-input", placeholder: "root" %>
69
+ </div>
70
+ <div class="rn-filter-group">
71
+ <label>Password</label>
72
+ <%= f.password_field :password, class: "rn-input", placeholder: "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022" %>
73
+ <p class="text-xs mt-1" style="color: var(--rn-text-muted)">ENV[VAR], ${VAR}, credentials[:key], or plain text</p>
74
+ </div>
75
+ <div class="rn-filter-group md:col-span-3">
76
+ <label>Skip Tables (comma-separated)</label>
77
+ <%= f.text_field :skip_tables, value: @config.skip_tables_list.join(", "), class: "rn-input", placeholder: "e.g. sessions, versions, internal_logs" %>
78
+ </div>
79
+ </div>
80
+ </div>
81
+
82
+ <%# ─── Storage ─────────────────────────────────────────────── %>
83
+ <div class="rn-card">
84
+ <div class="p-4" style="border-bottom: 1px solid var(--rn-border-light)">
85
+ <h2 class="text-sm font-semibold" style="color: var(--rn-text)">Storage</h2>
86
+ </div>
87
+ <div class="p-4 grid grid-cols-1 md:grid-cols-2 gap-4">
88
+ <div class="rn-filter-group">
89
+ <label>Storage Path *</label>
90
+ <%= f.text_field :storage_path, class: "rn-input", placeholder: "~/dumps" %>
91
+ </div>
92
+ <div class="rn-filter-group">
93
+ <label>Keep Count</label>
94
+ <%= f.number_field :keep_count, class: "rn-input", min: 1 %>
95
+ </div>
96
+ </div>
97
+ </div>
98
+
99
+ <%# ─── Archives ────────────────────────────────────────────── %>
100
+ <div class="rn-card">
101
+ <div class="p-4" style="border-bottom: 1px solid var(--rn-border-light)">
102
+ <h2 class="text-sm font-semibold" style="color: var(--rn-text)">Archives (File/Directory Backup)</h2>
103
+ </div>
104
+ <div class="p-4 grid grid-cols-1 md:grid-cols-2 gap-4">
105
+ <div class="rn-filter-group">
106
+ <label style="display:flex;align-items:center;gap:8px;cursor:pointer">
107
+ <%= f.check_box :archive_enabled %>
108
+ Enable Archives
109
+ </label>
110
+ <p class="text-xs mt-1" style="color: var(--rn-text-muted)">Include additional files/directories in the backup tar</p>
111
+ </div>
112
+ <div class="rn-filter-group md:col-span-2" id="archive-paths-group">
113
+ <label>Archive Paths (comma-separated)</label>
114
+ <%= f.text_field :archive_paths, value: @config.archive_paths_list.join(", "), class: "rn-input", placeholder: "e.g. /var/log, /etc/nginx, /home/app/uploads" %>
115
+ </div>
116
+ <div class="rn-filter-group md:col-span-2" id="archive-excludes-group">
117
+ <label>Exclude Patterns</label>
118
+ <%= f.text_field :archive_excludes, value: @config.archive_excludes_list.join(", "), class: "rn-input", placeholder: "e.g. *.log, tmp/*, node_modules" %>
119
+ </div>
120
+ </div>
121
+ </div>
122
+
123
+ <%# ─── Compression & Encryption ────────────────────────────── %>
124
+ <div class="rn-card">
125
+ <div class="p-4" style="border-bottom: 1px solid var(--rn-border-light)">
126
+ <h2 class="text-sm font-semibold" style="color: var(--rn-text)">Compression & Encryption</h2>
127
+ </div>
128
+ <div class="p-4 grid grid-cols-1 md:grid-cols-2 gap-4">
129
+ <div class="rn-filter-group">
130
+ <label style="display:flex;align-items:center;gap:8px;cursor:pointer">
131
+ <%= f.check_box :compress %>
132
+ Gzip Compression
133
+ </label>
134
+ </div>
135
+ <div class="rn-filter-group">
136
+ <label style="display:flex;align-items:center;gap:8px;cursor:pointer">
137
+ <%= f.check_box :bzip2_compress %>
138
+ Bzip2 Compression
139
+ </label>
140
+ <p class="text-xs mt-1" style="color: var(--rn-text-muted)">Better ratio but slower than Gzip</p>
141
+ </div>
142
+ <div class="rn-filter-group">
143
+ <label style="display:flex;align-items:center;gap:8px;cursor:pointer">
144
+ <%= f.check_box :encrypted %>
145
+ OpenSSL AES-256 Encryption
146
+ </label>
147
+ </div>
148
+ <div class="rn-filter-group" id="encrypt-password-group" style="<%= 'display:none' unless @config.encrypted? %>">
149
+ <label>Encryption Password</label>
150
+ <%= f.password_field :encryption_password, class: "rn-input", placeholder: "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022" %>
151
+ <p class="text-xs mt-1" style="color: var(--rn-text-muted)">ENV[VAR], ${VAR}, credentials[:key], or plain text</p>
152
+ </div>
153
+ <div class="rn-filter-group">
154
+ <label style="display:flex;align-items:center;gap:8px;cursor:pointer">
155
+ <%= f.check_box :gpg_enabled %>
156
+ GPG Encryption
157
+ </label>
158
+ </div>
159
+ <div class="rn-filter-group" id="gpg-password-group" style="<%= 'display:none' unless @config.gpg_enabled? %>">
160
+ <label>GPG Password</label>
161
+ <%= f.password_field :gpg_password, class: "rn-input", placeholder: "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022" %>
162
+ <p class="text-xs mt-1" style="color: var(--rn-text-muted)">ENV[VAR], ${VAR}, credentials[:key], or plain text</p>
163
+ </div>
164
+ <div class="rn-filter-group">
165
+ <label style="display:flex;align-items:center;gap:8px;cursor:pointer">
166
+ <%= f.check_box :split_chunks %>
167
+ Split into Chunks (50MB)
168
+ </label>
169
+ <p class="text-xs mt-1" style="color: var(--rn-text-muted)">Split large backups into 50MB files</p>
170
+ </div>
171
+ </div>
172
+ </div>
173
+
174
+ <%# ─── Remote Sync (RSync) ────────────────────────────────── %>
175
+ <div class="rn-card">
176
+ <div class="p-4" style="border-bottom: 1px solid var(--rn-border-light)">
177
+ <h2 class="text-sm font-semibold" style="color: var(--rn-text)">Remote Sync (RSync)</h2>
178
+ </div>
179
+ <div class="p-4 grid grid-cols-1 md:grid-cols-2 gap-4">
180
+ <div class="rn-filter-group">
181
+ <label style="display:flex;align-items:center;gap:8px;cursor:pointer">
182
+ <%= f.check_box :rsync_enabled %>
183
+ Enable RSync
184
+ </label>
185
+ </div>
186
+ <div class="rn-filter-group" id="rsync-fields" style="<%= 'display:none' unless @config.rsync_enabled? %>">
187
+ <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
188
+ <div>
189
+ <label>Host</label>
190
+ <%= f.text_field :rsync_host, class: "rn-input", placeholder: "192.168.1.100" %>
191
+ </div>
192
+ <div>
193
+ <label>Port</label>
194
+ <%= f.number_field :rsync_port, class: "rn-input", value: @config.rsync_port || 22 %>
195
+ </div>
196
+ <div>
197
+ <label>SSH User</label>
198
+ <%= f.text_field :rsync_user, class: "rn-input", placeholder: "backup" %>
199
+ </div>
200
+ <div>
201
+ <label>Remote Path</label>
202
+ <%= f.text_field :rsync_path, class: "rn-input", placeholder: "/backups" %>
203
+ </div>
204
+ <div>
205
+ <label style="display:flex;align-items:center;gap:8px;cursor:pointer">
206
+ <%= f.check_box :rsync_mirror %>
207
+ Mirror Mode
208
+ </label>
209
+ </div>
210
+ </div>
211
+ </div>
212
+ </div>
213
+ </div>
214
+
215
+ <%# ─── S3 Storage ──────────────────────────────────────────── %>
216
+ <div class="rn-card">
217
+ <div class="p-4" style="border-bottom: 1px solid var(--rn-border-light)">
218
+ <h2 class="text-sm font-semibold" style="color: var(--rn-text)">S3 Storage</h2>
219
+ </div>
220
+ <div class="p-4 grid grid-cols-1 md:grid-cols-2 gap-4">
221
+ <div class="rn-filter-group">
222
+ <label style="display:flex;align-items:center;gap:8px;cursor:pointer">
223
+ <%= f.check_box :s3_enabled %>
224
+ Enable S3 Upload
225
+ </label>
226
+ <p class="text-xs mt-1" style="color: var(--rn-text-muted)">Requires aws-cli or s3cmd installed</p>
227
+ </div>
228
+ <div class="rn-filter-group md:col-span-2" id="s3-fields" style="<%= 'display:none' unless @config.s3_enabled? %>">
229
+ <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
230
+ <div>
231
+ <label>Access Key *</label>
232
+ <%= f.text_field :s3_access_key, class: "rn-input", placeholder: "AKIA..." %>
233
+ </div>
234
+ <div>
235
+ <label>Secret Key *</label>
236
+ <%= f.password_field :s3_secret_key, class: "rn-input", placeholder: "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022" %>
237
+ </div>
238
+ <div>
239
+ <label>Bucket *</label>
240
+ <%= f.text_field :s3_bucket, class: "rn-input", placeholder: "my-backups-bucket" %>
241
+ </div>
242
+ <div>
243
+ <label>Region</label>
244
+ <%= f.text_field :s3_region, class: "rn-input", placeholder: "us-east-1", value: @config.s3_region || "us-east-1" %>
245
+ </div>
246
+ <div>
247
+ <label>Key Prefix</label>
248
+ <%= f.text_field :s3_prefix, class: "rn-input", placeholder: "backups/production" %>
249
+ </div>
250
+ </div>
251
+ </div>
252
+ </div>
253
+ </div>
254
+
255
+ <%# ─── Notifications ──────────────────────────────────────── %>
256
+ <div class="rn-card">
257
+ <div class="p-4" style="border-bottom: 1px solid var(--rn-border-light)">
258
+ <h2 class="text-sm font-semibold" style="color: var(--rn-text)">Notifications</h2>
259
+ </div>
260
+ <div class="p-4 grid grid-cols-1 md:grid-cols-2 gap-4">
261
+ <div class="rn-filter-group md:col-span-2">
262
+ <label>Notify Command (Webhook / Script)</label>
263
+ <%= f.text_field :notify_command, class: "rn-input", placeholder: "e.g. curl -X POST https://hooks.slack.com/..." %>
264
+ <p class="text-xs mt-1" style="color: var(--rn-text-muted)">Variables: {STATUS}, {MODEL}, {ERROR}, {TIME}</p>
265
+ </div>
266
+ <div class="rn-filter-group">
267
+ <label style="display:flex;align-items:center;gap:8px;cursor:pointer">
268
+ <%= f.check_box :notify_on_success %>
269
+ Notify on Success
270
+ </label>
271
+ </div>
272
+ <div class="rn-filter-group">
273
+ <label style="display:flex;align-items:center;gap:8px;cursor:pointer">
274
+ <%= f.check_box :notify_on_failure %>
275
+ Notify on Failure
276
+ </label>
277
+ </div>
278
+ <div class="rn-filter-group">
279
+ <label style="display:flex;align-items:center;gap:8px;cursor:pointer">
280
+ <%= f.check_box :email_notify %>
281
+ Email Notifications
282
+ </label>
283
+ </div>
284
+ <div class="rn-filter-group" id="email-to-group" style="<%= 'display:none' unless @config.email_notify? %>">
285
+ <label>Email To</label>
286
+ <%= f.text_field :email_to, class: "rn-input", placeholder: "admin@example.com" %>
287
+ </div>
288
+ </div>
289
+ </div>
290
+
291
+ <%# ─── Schedule ────────────────────────────────────────────── %>
292
+ <div class="rn-card">
293
+ <div class="p-4" style="border-bottom: 1px solid var(--rn-border-light)">
294
+ <h2 class="text-sm font-semibold" style="color: var(--rn-text)">Schedule</h2>
295
+ </div>
296
+ <div class="p-4">
297
+ <div class="rn-filter-group" style="max-width: 300px">
298
+ <label>Cron Expression</label>
299
+ <%= f.text_field :schedule_cron, class: "rn-input", placeholder: "e.g. 0 2 * * * (daily at 2am)" %>
300
+ <p class="text-xs mt-1" style="color: var(--rn-text-muted)">min hour day month wday &mdash; leave blank for manual only</p>
301
+ </div>
302
+ </div>
303
+ </div>
304
+
305
+ <%# ─── Submit ──────────────────────────────────────────────── %>
306
+ <div class="flex items-center gap-3">
307
+ <%= f.submit @config.persisted? ? "Update Config" : "Create Config", class: "rn-btn rn-btn-primary" %>
308
+ <%= link_to "Cancel", backup_path, class: "rn-btn rn-btn-ghost" %>
309
+ </div>
310
+ <% end %>
311
+
312
+ <script>
313
+ document.addEventListener("DOMContentLoaded", function() {
314
+ // Toggle visibility for dependent sections based on checkboxes
315
+ function setupToggle(checkboxId, targetId) {
316
+ var cb = document.getElementById(checkboxId);
317
+ var target = document.getElementById(targetId);
318
+ if (!cb || !target) return;
319
+ cb.addEventListener("change", function() {
320
+ target.style.display = cb.checked ? "" : "none";
321
+ });
322
+ // Set initial state
323
+ target.style.display = cb.checked ? "" : "none";
324
+ }
325
+
326
+ setupToggle("backup_config_encrypted", "encrypt-password-group");
327
+ setupToggle("backup_config_gpg_enabled", "gpg-password-group");
328
+ setupToggle("backup_config_rsync_enabled", "rsync-fields");
329
+ setupToggle("backup_config_s3_enabled", "s3-fields");
330
+ setupToggle("backup_config_email_notify", "email-to-group");
331
+ setupToggle("backup_config_archive_enabled", "archive-paths-group");
332
+ setupToggle("backup_config_archive_enabled", "archive-excludes-group");
333
+ });
334
+ </script>
@@ -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>
@@ -0,0 +1,15 @@
1
+ <% page_title "Edit #{@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">Edit <%= @config.name %></span>
10
+ </nav>
11
+
12
+ <h1 class="text-2xl font-bold" style="color: var(--rn-text)">Edit: <%= @config.name %></h1>
13
+
14
+ <%= render "form" %>
15
+ </div>
@@ -0,0 +1,75 @@
1
+ <% page_title "Backup History" %>
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">History</span>
10
+ </nav>
11
+
12
+ <div class="flex items-center justify-between">
13
+ <div>
14
+ <h1 class="text-2xl font-bold" style="color: var(--rn-text)">Backup History</h1>
15
+ <p class="text-sm mt-1" style="color: var(--rn-text-muted)">Review completed runs, failures, and backup logs.</p>
16
+ </div>
17
+ <div class="flex items-center gap-2">
18
+ <%= link_to "Configurations", backup_path, class: "rn-btn rn-btn-ghost" %>
19
+ <%= link_to "New Config", new_backup_path, class: "rn-btn rn-btn-primary" %>
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>Config</th>
30
+ <th>Status</th>
31
+ <th>File</th>
32
+ <th>Size</th>
33
+ <th>Duration</th>
34
+ <th>Triggered By</th>
35
+ <th>Time</th>
36
+ <th>Error</th>
37
+ </tr>
38
+ </thead>
39
+ <tbody>
40
+ <% @records.each do |record| %>
41
+ <tr>
42
+ <td><span class="font-medium" style="color: var(--rn-text)"><%= record.config_name %></span></td>
43
+ <td>
44
+ <span class="rn-badge rn-badge-<%= record.status == 'success' ? 'success' : record.status == 'failed' ? 'danger' : 'warning' %>">
45
+ <%= record.status.capitalize %>
46
+ </span>
47
+ <% if record.status == 'failed' && record.error_message.present? %>
48
+ <div class="text-xs mt-1" style="color: var(--rn-danger); cursor: pointer; max-width: 300px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap" title="Click to expand" onclick="var el=this.nextElementSibling; el.style.display=el.style.display==='none'?'block':'none'">
49
+ ✖ <%= truncate(record.error_message, length: 60) %>
50
+ </div>
51
+ <pre class="text-xs" style="display:none; color: var(--rn-danger); background: var(--rn-bg-secondary); padding: 8px; border-radius: 4px; margin-top: 4px; max-height: 120px; overflow: auto; white-space: pre-wrap; word-break: break-word"><%= record.error_message %></pre>
52
+ <% end %>
53
+ </td>
54
+ <td><span class="text-xs rn-mono" style="color: var(--rn-text-secondary)"><%= record.file_path ? File.basename(record.file_path) : "—" %></span></td>
55
+ <td><span class="text-xs" style="color: var(--rn-text-secondary)"><%= record.file_size ? number_to_human_size(record.file_size) : "—" %></span></td>
56
+ <td><span class="text-xs" style="color: var(--rn-text-secondary)"><%= record.duration ? "#{record.duration.round(1)}s" : "—" %></span></td>
57
+ <td><span class="text-xs" style="color: var(--rn-text-muted)"><%= record.triggered_by || "—" %></span></td>
58
+ <td><span class="text-xs" style="color: var(--rn-text-muted)"><%= record.started_at&.strftime("%b %d %H:%M") %></span></td>
59
+ <td>
60
+ <% if record.error_message.present? %>
61
+ <span class="text-xs" style="color: var(--rn-danger)" title="<%= record.error_message %>">⚠</span>
62
+ <% end %>
63
+ </td>
64
+ </tr>
65
+ <% end %>
66
+ </tbody>
67
+ </table>
68
+ </div>
69
+ <% else %>
70
+ <div class="p-8 text-center">
71
+ <p class="text-sm" style="color: var(--rn-text-muted)">No backup records found.</p>
72
+ </div>
73
+ <% end %>
74
+ </div>
75
+ </div>
@@ -0,0 +1,112 @@
1
+ <% page_title "Backup Management" %>
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
+ <span class="rn-breadcrumb-current">Backups</span>
8
+ </nav>
9
+
10
+ <div class="flex items-center justify-between">
11
+ <div>
12
+ <h1 class="text-2xl font-bold" style="color: var(--rn-text)">Backup Configurations</h1>
13
+ <p class="text-sm mt-1" style="color: var(--rn-text-muted)">Configure storage, schedules, and run backups.</p>
14
+ </div>
15
+ <div class="flex items-center gap-2">
16
+ <%= link_to "Backup History", backup_history_path, class: "rn-btn rn-btn-ghost" %>
17
+ <%= link_to "New Config", new_backup_path, class: "rn-btn rn-btn-primary" %>
18
+ </div>
19
+ </div>
20
+
21
+ <%# ─── Backup Configs ─────────────────────────────────────── %>
22
+ <div class="rn-card">
23
+ <div class="p-4" style="border-bottom: 1px solid var(--rn-border-light)">
24
+ <h2 class="text-sm font-semibold" style="color: var(--rn-text)">Backup Configurations</h2>
25
+ <p class="text-xs mt-1" style="color: var(--rn-text-muted)">Configure and run database backups</p>
26
+ </div>
27
+
28
+ <% if @configs.any? %>
29
+ <div class="rn-table-wrap">
30
+ <table class="rn-table">
31
+ <thead>
32
+ <tr>
33
+ <th>Name</th>
34
+ <th>Adapter</th>
35
+ <th>Database</th>
36
+ <th>Storage</th>
37
+ <th>Schedule</th>
38
+ <th>Status</th>
39
+ <th style="text-align: right">Actions</th>
40
+ </tr>
41
+ </thead>
42
+ <tbody>
43
+ <% @configs.each do |config| %>
44
+ <tr>
45
+ <td>
46
+ <span class="font-medium" style="color: var(--rn-text)"><%= config.name %></span>
47
+ <% if config.description.present? %>
48
+ <br><span class="text-xs" style="color: var(--rn-text-muted)"><%= config.description %></span>
49
+ <% end %>
50
+ </td>
51
+ <td><span class="rn-badge rn-badge-info"><%= config.adapter.upcase %></span></td>
52
+ <td><span class="text-xs rn-mono" style="color: var(--rn-text-secondary)"><%= config.database_name %></span></td>
53
+ <td><span class="text-xs" style="color: var(--rn-text-muted)"><%= config.storage_path %> (keep <%= config.keep_count %>)</span></td>
54
+ <td>
55
+ <% if config.schedule_cron.present? %>
56
+ <span class="rn-badge rn-badge-warning"><%= config.schedule_cron %></span>
57
+ <% else %>
58
+ <span class="text-xs" style="color: var(--rn-text-muted)">Manual</span>
59
+ <% end %>
60
+ </td>
61
+ <td>
62
+ <span class="rn-badge <%= config.enabled? ? 'rn-badge-success' : 'rn-badge-muted' %>">
63
+ <%= config.enabled? ? 'Enabled' : 'Disabled' %>
64
+ </span>
65
+ <% if config.encrypted? %><span class="rn-badge rn-badge-warning">Encrypted</span><% end %>
66
+ <% if config.rsync_enabled? %><span class="rn-badge rn-badge-info">RSync</span><% end %>
67
+ </td>
68
+ <td style="text-align: right">
69
+ <div class="flex items-center justify-end gap-1">
70
+ <% running = BackupNexus::Backup.where(config_name: config.name, status: "running").exists? %>
71
+ <% if running %>
72
+ <span class="rn-badge rn-badge-info">
73
+ <svg class="animate-spin" style="width:12px;height:12px;display:inline" viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" opacity="0.25"/><path d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" fill="currentColor"/></svg>
74
+ Running
75
+ </span>
76
+ <% else %>
77
+ <%= button_to "Run", backup_trigger_path(config),
78
+ method: :post,
79
+ class: "rn-btn rn-btn-primary rn-btn-xs",
80
+ data: { turbo_confirm: "Run #{config.name} backup now?" } %>
81
+ <% end %>
82
+ <%= link_to "Edit", edit_backup_path(config), class: "rn-btn rn-btn-ghost rn-btn-xs" %>
83
+ <%= button_to "Delete", backup_destroy_path(config),
84
+ method: :delete,
85
+ class: "rn-btn rn-btn-ghost rn-btn-xs",
86
+ style: "color: var(--rn-danger)",
87
+ data: { turbo_confirm: "Delete config '#{config.name}'?" } %>
88
+ </div>
89
+ </td>
90
+ </tr>
91
+ <% end %>
92
+ </tbody>
93
+ </table>
94
+ </div>
95
+ <% else %>
96
+ <div class="p-8 text-center">
97
+ <p class="text-sm" style="color: var(--rn-text-muted)">No backup configurations yet.</p>
98
+ <%= link_to "Create your first config", new_backup_path, class: "mt-3 inline-block text-xs font-medium", style: "color: var(--rn-primary)" %>
99
+ </div>
100
+ <% end %>
101
+ </div>
102
+ </div>
103
+
104
+ <script>
105
+ // Auto-refresh when a backup is running
106
+ (function() {
107
+ var running = document.querySelector('.animate-spin');
108
+ if (running) {
109
+ setTimeout(function() { location.reload(); }, 5000);
110
+ }
111
+ })();
112
+ </script>
@@ -0,0 +1,15 @@
1
+ <% page_title "New Backup Config" %>
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">New Config</span>
10
+ </nav>
11
+
12
+ <h1 class="text-2xl font-bold" style="color: var(--rn-text)">New Backup Configuration</h1>
13
+
14
+ <%= render "form" %>
15
+ </div>
@@ -0,0 +1,30 @@
1
+ require_relative "lib/backup_nexus/version"
2
+
3
+ Gem::Specification.new do |spec|
4
+ spec.name = "backup_nexus"
5
+ spec.version = BackupNexus::VERSION
6
+ spec.authors = ["Tamiru Hailu"]
7
+ spec.email = ["tamiruhailu@gmail.com"]
8
+ spec.homepage = "https://github.com/tamiru/backup_nexus"
9
+ spec.summary = "Backup management for Rails applications"
10
+ spec.description = "A Rails engine for configuring, running, tracking, and retaining database and file backups."
11
+ spec.license = "MIT"
12
+
13
+ spec.metadata["rubygems_mfa_required"] = "true"
14
+ spec.metadata["allowed_push_host"] = "https://rubygems.org"
15
+ spec.metadata["homepage_uri"] = spec.homepage
16
+ spec.metadata["source_code_uri"] = "https://github.com/tamiru/backup_nexus/tree/main"
17
+ spec.metadata["changelog_uri"] = "https://github.com/tamiru/backup_nexus/blob/main/CHANGELOG.md"
18
+ spec.metadata["bug_tracker_uri"] = "https://github.com/tamiru/backup_nexus/issues"
19
+
20
+ spec.files = Dir.chdir(File.expand_path(__dir__)) do
21
+ Dir["{app,config,db,lib}/**/*", "README.md", "CHANGELOG.md", "MIT-LICENSE", "Rakefile", "backup_nexus.gemspec"]
22
+ .reject { |file| file.match?(%r{(?:^|/)(?:log|tmp|storage|pkg)(?:/|$)|\.(?:gem|sqlite3|log)\z}) }
23
+ end
24
+
25
+ spec.required_ruby_version = ">= 3.2.0"
26
+
27
+ spec.add_dependency "rails", "~> 8.0", ">= 8.0.0"
28
+ spec.add_dependency "rails_nexus", ">= 2.1.3", "< 3.0"
29
+ spec.add_dependency "pagy", "~> 6.5"
30
+ end