easy_sync 0.0.5 → 2.0.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 (57) hide show
  1. checksums.yaml +5 -5
  2. data/.gitignore +8 -0
  3. data/.rspec +2 -0
  4. data/.ruby-version +1 -0
  5. data/CHANGELOG.md +85 -0
  6. data/CLAUDE.md +133 -0
  7. data/Gemfile +2 -0
  8. data/README.md +607 -21
  9. data/Rakefile +6 -8
  10. data/bin/easy_sync +8 -1
  11. data/docs/fine-placement.md +174 -0
  12. data/docs/hardware-notes.md +71 -0
  13. data/docs/integrity-scan.md +435 -0
  14. data/docs/parallel-scrub.md +280 -0
  15. data/docs/performance.md +146 -0
  16. data/docs/tripwire.md +232 -0
  17. data/easy_sync.gemspec +25 -16
  18. data/lib/easy_sync/cli.rb +1246 -0
  19. data/lib/easy_sync/config.rb +202 -0
  20. data/lib/easy_sync/error.rb +5 -0
  21. data/lib/easy_sync/jbod/backblaze.rb +113 -0
  22. data/lib/easy_sync/jbod/benchmarker.rb +149 -0
  23. data/lib/easy_sync/jbod/cleaner.rb +93 -0
  24. data/lib/easy_sync/jbod/dashboard.rb +611 -0
  25. data/lib/easy_sync/jbod/keep_awake.rb +33 -0
  26. data/lib/easy_sync/jbod/manifest.rb +1068 -0
  27. data/lib/easy_sync/jbod/mirror.rb +204 -0
  28. data/lib/easy_sync/jbod/models.rb +165 -0
  29. data/lib/easy_sync/jbod/page_cache.rb +56 -0
  30. data/lib/easy_sync/jbod/placement.rb +71 -0
  31. data/lib/easy_sync/jbod/planner.rb +76 -0
  32. data/lib/easy_sync/jbod/purger.rb +161 -0
  33. data/lib/easy_sync/jbod/restorer.rb +142 -0
  34. data/lib/easy_sync/jbod/run_lock.rb +98 -0
  35. data/lib/easy_sync/jbod/run_log.rb +57 -0
  36. data/lib/easy_sync/jbod/runner.rb +615 -0
  37. data/lib/easy_sync/jbod/scrub_pool.rb +136 -0
  38. data/lib/easy_sync/jbod/scrubber.rb +321 -0
  39. data/lib/easy_sync/jbod/share_scan.rb +26 -0
  40. data/lib/easy_sync/jbod/sync_eta.rb +68 -0
  41. data/lib/easy_sync/jbod/tripwire.rb +80 -0
  42. data/lib/easy_sync/jbod/volume_info.rb +283 -0
  43. data/lib/easy_sync/jbod.rb +35 -0
  44. data/lib/easy_sync/shell.rb +62 -0
  45. data/lib/easy_sync/templates/dashboard.html.erb +419 -0
  46. data/lib/easy_sync/version.rb +3 -1
  47. data/lib/easy_sync.rb +7 -3
  48. metadata +84 -45
  49. data/Gemfile.lock +0 -55
  50. data/Guardfile +0 -28
  51. data/lib/easy_sync/rsync.rb +0 -60
  52. data/lib/easy_sync/sync_logger.rb +0 -20
  53. data/lib/easy_sync/sync_runner.rb +0 -44
  54. data/spec/easy_sync/rsync_spec.rb +0 -25
  55. data/spec/easy_sync/sync_runner_spec.rb +0 -54
  56. data/spec/spec_helper.rb +0 -33
  57. data/spec/support.rb +0 -89
@@ -0,0 +1,419 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Easy Sync Backup Status</title>
7
+ <style>
8
+ :root {
9
+ color-scheme: light dark;
10
+ --page: #f9f9f7; --surface: #fcfcfb; --ink: #0b0b0b; --ink-2: #52514e; --muted: #898781;
11
+ --hairline: #e1e0d9; --ring: rgba(11,11,11,0.10);
12
+ --accent: #2a78d6; --track: #e6e5df;
13
+ --good: #0ca30c; --warning: #fab219; --critical: #d03b3b; --stable: #5b8fd6;
14
+ --warning-track: #fdebc2; --critical-track: #f6cfcf;
15
+ }
16
+ @media (prefers-color-scheme: dark) {
17
+ :root:not([data-theme="light"]) {
18
+ --page: #0d0d0d; --surface: #1a1a19; --ink: #ffffff; --ink-2: #c3c2b7; --muted: #898781;
19
+ --hairline: #2c2c2a; --ring: rgba(255,255,255,0.10);
20
+ --accent: #3987e5; --track: #333331; --stable: #6fa1e0;
21
+ --warning-track: #4a3708; --critical-track: #4a1717;
22
+ }
23
+ }
24
+ :root[data-theme="dark"] {
25
+ --page: #0d0d0d; --surface: #1a1a19; --ink: #ffffff; --ink-2: #c3c2b7; --muted: #898781;
26
+ --hairline: #2c2c2a; --ring: rgba(255,255,255,0.10);
27
+ --accent: #3987e5; --track: #333331; --stable: #6fa1e0;
28
+ --warning-track: #4a3708; --critical-track: #4a1717;
29
+ }
30
+ * { box-sizing: border-box; }
31
+ body { margin: 0; background: var(--page); color: var(--ink);
32
+ font: 14px/1.45 system-ui, -apple-system, "Segoe UI", sans-serif; }
33
+ main { max-width: 1400px; margin: 0 auto; padding-block: 32px; padding-inline: 20px; }
34
+ h1 { font-size: 22px; font-weight: 600; margin: 0 0 4px; }
35
+ h2 { font-size: 15px; font-weight: 600; margin: 32px 0 12px; color: var(--ink); }
36
+ h3 { font-size: 13px; font-weight: 600; margin: 20px 0 8px; color: var(--ink); }
37
+ .capacity { color: var(--ink-2); font-size: 16px; margin: 0 0 6px; }
38
+ .capacity strong { color: var(--ink); font-weight: 600; }
39
+ .meta { color: var(--muted); margin: 0 0 20px; }
40
+ .eta { color: var(--ink-2); margin: -12px 0 20px; }
41
+ .alerts { display: grid; gap: 8px; margin: 0 0 20px; }
42
+ .alert { border: 1px solid var(--ring); border-left: 4px solid var(--warning); background: var(--surface);
43
+ border-radius: 6px; padding: 10px 14px; }
44
+ .alert.critical { border-left-color: var(--critical); }
45
+ .alert.unknown { border-left-color: var(--muted); }
46
+ .tiles { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 12px; }
47
+ .tile { background: var(--surface); border: 1px solid var(--ring); border-radius: 8px; padding: 14px 16px; }
48
+ .tile.unmounted { opacity: 0.7; }
49
+ .tile .name { font-weight: 600; display: flex; justify-content: space-between; align-items: baseline; gap: 8px; }
50
+ .tile .serial { color: var(--muted); font-size: 12px; overflow-wrap: anywhere; }
51
+ .tile .value { font-size: 26px; font-weight: 600; margin: 8px 0 2px; }
52
+ .tile .value .unit { font-size: 14px; font-weight: 400; color: var(--ink-2); }
53
+ .tile .sub { color: var(--ink-2); }
54
+ .tile .sub.muted { color: var(--muted); }
55
+ .meter { height: 8px; border-radius: 4px; background: var(--track); margin: 10px 0 8px; overflow: hidden; }
56
+ .meter > span { display: block; height: 100%; background: var(--accent); border-radius: 4px; }
57
+ .tile.stable { border-color: var(--stable); box-shadow: inset 4px 0 0 var(--stable); }
58
+ .tile.warning { border-color: var(--warning); box-shadow: inset 4px 0 0 var(--warning); }
59
+ .tile.critical { border-color: var(--critical); box-shadow: inset 4px 0 0 var(--critical); }
60
+ .tile .health { font-size: 12px; margin-top: 8px; color: var(--ink-2); }
61
+ .tile .health::before { content: "●"; margin-right: 4px; }
62
+ .tile .health.ok::before { color: var(--good); }
63
+ .tile .health.degraded_stable::before { color: var(--stable); }
64
+ .tile .health.warning::before { color: var(--warning); }
65
+ .tile .health.failing::before { color: var(--critical); }
66
+ .tile .health.unknown::before { color: var(--muted); }
67
+ .tile .health small { color: var(--muted); display: block; margin-left: 12px; }
68
+ .badge { font-size: 12px; font-weight: 500; color: var(--ink-2); white-space: nowrap; }
69
+ .badge::before { content: "●"; margin-right: 4px; }
70
+ .badge.ok::before { color: var(--good); }
71
+ .badge.warning::before { color: var(--warning); }
72
+ .badge.critical::before { color: var(--critical); }
73
+ .badge.unknown::before { color: var(--muted); }
74
+ .tile ul { margin: 8px 0 0; padding-left: 18px; color: var(--ink-2); }
75
+ .table-wrap { overflow-x: auto; background: var(--surface); border: 1px solid var(--ring); border-radius: 8px; }
76
+ table { border-collapse: collapse; width: 100%; }
77
+ th, td { text-align: left; padding: 8px 12px; border-bottom: 1px solid var(--hairline); vertical-align: top; }
78
+ th { color: var(--muted); font-weight: 500; font-size: 12px; }
79
+ tr:last-child td { border-bottom: 0; }
80
+ td.num { font-variant-numeric: tabular-nums; text-align: right; white-space: nowrap; }
81
+ td.drive, td.when { white-space: nowrap; }
82
+ td.muted, span.muted { color: var(--muted); }
83
+ details.group > summary { cursor: pointer; }
84
+ details.group ul { margin: 6px 0 0; padding-left: 18px; color: var(--ink-2); max-height: 240px; overflow-y: auto; }
85
+ th.num { text-align: right; }
86
+ .status { white-space: nowrap; }
87
+ .status::before { content: "●"; margin-right: 4px; color: var(--muted); }
88
+ .status.ok::before { color: var(--good); }
89
+ .status.failed::before, .status.drive_full::before { color: var(--critical); }
90
+ .status.tripped::before { color: var(--critical); }
91
+ .status.skipped_unmounted::before, .status.skipped_source_unmounted::before, .status.missing::before,
92
+ .status.skipped_check_failed::before { color: var(--warning); }
93
+ .samples { margin: 4px 0 0; padding-left: 16px; color: var(--muted); font-size: 13px; word-break: break-all; }
94
+ .empty { color: var(--muted); padding: 14px; }
95
+ details.share { border-bottom: 1px solid var(--hairline); }
96
+ details.share:last-child { border-bottom: 0; }
97
+ details.share > summary { cursor: pointer; padding: 10px 12px; list-style: none; display: flex; gap: 12px; align-items: baseline; }
98
+ details.share > summary::-webkit-details-marker { display: none; }
99
+ details.share > summary::before { content: "▸"; color: var(--muted); width: 12px; display: inline-block; }
100
+ details.share[open] > summary::before { content: "▾"; }
101
+ details.share > summary .share-name { font-weight: 600; }
102
+ details.share > summary .share-meta { color: var(--muted); font-size: 13px; }
103
+ details.share > summary .share-problems { color: var(--critical); font-size: 13px; font-weight: 500; }
104
+ details.share.attention { border-left: 4px solid var(--critical); }
105
+ details.share table { border-top: 1px solid var(--hairline); }
106
+ .tile .shares { margin: 10px 0 0; padding: 0; list-style: none; }
107
+ .tile .shares li { display: flex; justify-content: space-between; gap: 8px; color: var(--ink-2); padding: 2px 0; }
108
+ .tile .shares li span:last-child { color: var(--muted); white-space: nowrap; }
109
+ .tile details { margin-top: 6px; }
110
+ .tile details > summary { cursor: pointer; color: var(--muted); font-size: 12px; }
111
+ .tile details ul { margin: 6px 0 0; padding-left: 18px; color: var(--ink-2); font-size: 13px; max-height: 240px; overflow-y: auto; }
112
+ .verdict { background: var(--surface); border: 1px solid var(--ring); border-left: 6px solid var(--good);
113
+ border-radius: 8px; padding: 16px 18px; margin: 12px 0 20px; }
114
+ .verdict.warning { border-left-color: var(--warning); }
115
+ .verdict.critical { border-left-color: var(--critical); }
116
+ .verdict .headline { font-size: 20px; font-weight: 600; margin: 0; }
117
+ .verdict .headline::before { content: "●"; margin-right: 8px; color: var(--good); }
118
+ .verdict.warning .headline::before { color: var(--warning); }
119
+ .verdict.critical .headline::before { color: var(--critical); }
120
+ .verdict .line { color: var(--ink-2); margin: 6px 0 0; }
121
+ .verdict .line.muted { color: var(--muted); font-size: 13px; }
122
+ .verdict .alerts { margin: 14px 0 0; }
123
+ .section-line { margin: 28px 0 0; color: var(--muted); }
124
+ .section-line strong { color: var(--ink); font-size: 15px; margin-right: 6px; }
125
+ .tile .seen.warning { color: var(--warning); font-weight: 500; }
126
+ .tile .seen.critical { color: var(--critical); font-weight: 500; }
127
+ .tile .specs { color: var(--muted); font-size: 12px; margin: 6px 0 4px; }
128
+ .day { margin: 0; padding: 8px 12px; font-size: 12px; font-weight: 600; color: var(--muted);
129
+ border-bottom: 1px solid var(--hairline); background: var(--page); }
130
+ .feed { list-style: none; margin: 0; padding: 0; }
131
+ .feed > li { display: grid; grid-template-columns: 52px 1fr; gap: 12px; padding: 8px 12px; border-bottom: 1px solid var(--hairline); }
132
+ .feed > li:last-child { border-bottom: 0; }
133
+ .feed .time { color: var(--muted); font-variant-numeric: tabular-nums; }
134
+ .feed .note { color: var(--muted); font-size: 13px; }
135
+ .feed .kind-deleted .what::before { content: "− "; color: var(--critical); }
136
+ .feed .kind-sync .what::before { content: "↻ "; color: var(--muted); }
137
+ @media (max-width: 600px) {
138
+ main { padding-inline: 16px; }
139
+ .verdict .headline { font-size: 17px; }
140
+ th, td { padding: 6px 8px; }
141
+ }
142
+ </style>
143
+ </head>
144
+ <body>
145
+ <main>
146
+ <h1>Easy Sync Backup Status</h1>
147
+
148
+ <section class="verdict <%= verdict.level %>">
149
+ <p class="headline"><%= h verdict.headline %></p>
150
+ <%- if running && running.kind != 'sync' -%>
151
+ <p class="line"><%= h other_running_line(running) %></p>
152
+ <%- elsif (eta_text = eta_line(eta)) -%>
153
+ <p class="line">Sync in progress: <%= h eta_text %></p>
154
+ <%- end -%>
155
+ <%- if (last = runs.first) -%>
156
+ <p class="line">Last sync <%= relative_when(last.run_started_at) %> · <%= in_progress?(last, runs) ? 'still running' : "took #{run_duration(last)}" %> ·
157
+ <%= last.copied.to_i.zero? ? 'nothing needed copying' : "#{count(last.copied)} folder#{'s' if last.copied != 1} copied (#{bytes(last.bytes_transferred)})" %><%= ", #{last.failed} failed" if last.failed.to_i.positive? %></p>
158
+ <%- end -%>
159
+ <%- if drives.any? -%>
160
+ <p class="line muted"><%= bytes(total_capacity_bytes) %> total · <%= bytes(total_free_bytes) %> free ·
161
+ <%= drives.size %> drive<%= 's' if drives.size != 1 %>, <%= drives.count(&:mounted) %> connected<%- if uploads -%> · <%= h backblaze_summary(uploads) %><%- end -%> · updated <%= relative_when(generated_at.utc.iso8601) %></p>
162
+ <%- end -%>
163
+ <%- if issues.empty? -%>
164
+ <p class="line muted">Nothing needs your attention.</p>
165
+ <%- else -%>
166
+ <div class="alerts">
167
+ <%- issues.each do |i| -%>
168
+ <div class="alert <%= i.level %>"><%= i.html %></div>
169
+ <%- end -%>
170
+ </div>
171
+ <%- end -%>
172
+ </section>
173
+
174
+ <%- unless trips.empty? -%>
175
+ <h2>Tripwire</h2>
176
+ <p class="meta"><%= run_stopped?(trips) ? 'The whole sync was stopped' : 'These folders were not copied' %> <%= when_(trips.first.run_started_at) %>: far more existing files would have been overwritten or are gone from the NAS than a library changes normally. Names like <code>*.locked</code> or garbled ones point to ransomware; a re-tag or re-rip you made yourself goes through with <code>easy_sync sync --accept-changes [FOLDER ...]</code>. Nothing here is copied until then.</p>
177
+ <div class="table-wrap">
178
+ <table>
179
+ <thead><tr><th>Folder</th><th class="num">Replaced</th><th class="num">Gone from NAS</th><th class="num">Files on drive</th></tr></thead>
180
+ <tbody>
181
+ <%- trips.each do |t| -%>
182
+ <tr>
183
+ <td><%= h t.folder_path %><%- unless t.samples.empty? -%><ul class="samples"><%- t.samples.each do |path| -%><li><%= h path %></li><%- end -%></ul><%- end -%></td>
184
+ <td class="num"><%= count(t.replaced) %></td>
185
+ <td class="num"><%= count(t.missing) %></td>
186
+ <td class="num"><%= count(t.files_on_drive) %></td>
187
+ </tr>
188
+ <%- end -%>
189
+ </tbody>
190
+ </table>
191
+ </div>
192
+ <%- end -%>
193
+
194
+ <h2>Drives</h2>
195
+ <%- if drives.any? -%>
196
+ <p class="meta">Tile colour shows SMART health, not fullness: a drive near 100% is doing its job.</p>
197
+ <%- end -%>
198
+ <%- if drives.empty? -%>
199
+ <p class="empty">No drives registered. Run <code>easy_sync register-drive /Volumes/&lt;drive&gt;</code>.</p>
200
+ <%- else -%>
201
+ <div class="tiles">
202
+ <%- drives.each do |d| -%>
203
+ <div class="tile <%= d.level %><%= d.mounted ? '' : ' unmounted' %>">
204
+ <div class="name"><span><%= h d.drive.friendly_name %></span>
205
+ <%- unless d.mounted -%>
206
+ <span class="badge unknown">not connected</span>
207
+ <%- end -%>
208
+ </div>
209
+ <div class="value"><%= percent(d.used_fraction) %> <span class="unit">full</span></div>
210
+ <div class="meter" role="img" aria-label="<%= percent(d.used_fraction) %> used"><span style="width: <%= ((d.used_fraction || 0) * 100).clamp(0, 100).round(1) %>%"></span></div>
211
+ <div class="sub"><%= bytes(d.free_bytes) %> free of <%= bytes(d.capacity_bytes) %></div>
212
+ <%- if (seen = seen_line(d)) -%>
213
+ <div class="sub muted seen <%= seen_level(d) %>"><%= h seen %></div>
214
+ <%- elsif unexpected_mount?(d) -%>
215
+ <div class="sub muted">at <%= h d.mount_point %></div>
216
+ <%- end -%>
217
+ <div class="health <%= d.health %>" title="<%= h d.drive.smart_detail %>"><%= h health_line(d) %><%- if health_detail_shown?(d) -%><small><%= h d.drive.smart_detail %></small><%- end -%></div>
218
+ <div class="sub muted"><%= h scrub_status_line(d) %><%- if scrub_overdue?(d) -%> <span class="badge warning">overdue</span><%- end -%></div>
219
+ <%- if uploads && (upload = uploads[d.drive.serial_number]) -%>
220
+ <div class="sub<%= ' muted' if upload.done? %>">Backblaze: <%= h upload.label %></div>
221
+ <%- end -%>
222
+ <%- unless d.folders.empty? -%>
223
+ <ul class="shares">
224
+ <%- by_share(d.folders).each do |share, fs| -%>
225
+ <li><span><%= h share %><%= fs.size == 1 && fs.first.folder_path == share ? '' : " · #{count(fs.size)} folder#{'s' if fs.size != 1}" %></span><span><%= bytes(total_size(fs)) %></span></li>
226
+ <%- end -%>
227
+ </ul>
228
+ <%- end -%>
229
+ <details><summary>Drive details<%= d.folders.empty? ? '' : " and #{count(d.folders.size)} folder#{'s' if d.folders.size != 1}" %></summary>
230
+ <div class="specs"><%= h d.drive.serial_number %><%= d.drive.branded_model ? " · #{h d.drive.branded_model}" : '' %><%- if d.drive.power_on_label -%><br>powered on <%= h d.drive.power_on_label %><%- end -%></div>
231
+ <%- unless d.folders.empty? -%>
232
+ <ul>
233
+ <%- d.folders.each do |f| -%>
234
+ <li><%= h f.folder_path %><%= f.root? ? " (loose files)" : "" %> <span style="color:var(--muted)">(<%= bytes(f.size_bytes) %>)</span></li>
235
+ <%- end -%>
236
+ </ul>
237
+ <%- end -%>
238
+ </details>
239
+ </div>
240
+ <%- end -%>
241
+ </div>
242
+ <%- end -%>
243
+
244
+ <h2>Latest sync</h2>
245
+ <div class="table-wrap">
246
+ <%- if runs.empty? -%>
247
+ <p class="empty">No sync runs recorded yet.</p>
248
+ <%- else -%>
249
+ <%- latest = runs.first -%>
250
+ <%- if latest_notable.empty? -%>
251
+ <p class="empty"><%= in_progress?(latest, runs) ? 'Nothing copied so far' : 'Nothing changed' %>: <%= count(latest.folders) %> folder<%= 's' if latest.folders != 1 %> checked, already up to date.</p>
252
+ <%- else -%>
253
+ <table>
254
+ <thead><tr><th>Folder</th><th>Drive</th><th>Result</th><th class="num">Transferred</th><th class="num">Folder size</th></tr></thead>
255
+ <tbody>
256
+ <%- latest_notable.each do |r| -%>
257
+ <tr>
258
+ <td><%= h r.folder_path %></td>
259
+ <td class="drive"><%= h names.fetch(r.drive_serial, r.drive_serial) %></td>
260
+ <td><span class="status <%= r.exit_status.to_i.zero? ? 'ok' : 'failed' %>"><%= r.exit_status.to_i.zero? ? 'copied' : "failed (exit #{r.exit_status})" %></span></td>
261
+ <td class="num"><%= bytes(r.bytes_transferred) %></td>
262
+ <td class="num"><%= bytes(r.total_size_bytes) %></td>
263
+ </tr>
264
+ <%- end -%>
265
+ </tbody>
266
+ </table>
267
+ <%- unchanged = latest.folders - latest_notable.size -%>
268
+ <%- if unchanged.positive? -%>
269
+ <p class="empty"><%= count(unchanged) %> other folder<%= 's' if unchanged != 1 %> <%= unchanged == 1 ? 'was' : 'were' %> already up to date.</p>
270
+ <%- end -%>
271
+ <%- end -%>
272
+ <%- end -%>
273
+ </div>
274
+
275
+ <h2>Folders</h2>
276
+ <div class="table-wrap">
277
+ <%- if folders.empty? -%>
278
+ <p class="empty">No folders placed yet.</p>
279
+ <%- else -%>
280
+ <%- unplaced_all = inventory.select { |e| e.state == 'unplaced' } -%>
281
+ <%- attention = folders.reject { |f| EasySync::Jbod::Dashboard::NEUTRAL.include?(folder_status(f, source_status)) } -%>
282
+ <%- unless attention.empty? -%>
283
+ <details class="share attention" open>
284
+ <summary>
285
+ <span class="share-name">Needs attention</span>
286
+ <span class="share-problems"><%= count(attention.size) %> folder<%= 's' if attention.size != 1 %> not in a good state</span>
287
+ </summary>
288
+ <%= folder_rows(attention, source_status, pending, names) %>
289
+ </details>
290
+ <%- end -%>
291
+ <%- by_share(folders, inventory).each do |share, fs| -%>
292
+ <%- problems = problems_in(fs, source_status) -%>
293
+ <%- unsynced = unsynced_in(fs, source_status) -%>
294
+ <details class="share">
295
+ <summary>
296
+ <span class="share-name"><%= h share %></span>
297
+ <%- inv = inventory_for(inventory, share) -%>
298
+ <span class="share-meta"><%- if inv[:total].positive? -%><%= count(inv[:placed]) %> of <%= count(inv[:total]) %> folders on the NAS placed · <%= bytes(total_size(fs)) %> of <%= bytes(inv[:total_bytes]) %><%- else -%><%= count(fs.size) %> folder<%= 's' if fs.size != 1 %> · <%= bytes(total_size(fs)) %><%- end -%><%= " · #{count(unsynced)} not yet synced" if unsynced.positive? %></span>
299
+ <%- if inv[:unplaced].any? -%><span class="share-problems"><%= count(inv[:unplaced].size) %> not backed up</span><%- end -%>
300
+ <%- if problems.positive? -%><span class="share-problems"><%= problems %> need<%= 's' if problems == 1 %> attention</span><%- end -%>
301
+ </summary>
302
+ <%- if fs.empty? -%><p class="empty">Nothing from this share fits on the mounted drives yet.</p><%- else -%><%= folder_rows(fs, source_status, pending, names) %><%- end -%>
303
+ </details>
304
+ <%- end -%>
305
+ <%- unless unplaced_all.empty? -%>
306
+ <details class="share attention">
307
+ <summary>
308
+ <span class="share-name">Not backed up</span>
309
+ <span class="share-problems"><%= count(unplaced_all.size) %> folder<%= 's' if unplaced_all.size != 1 %> · <%= bytes(unplaced_all.sum { |e| e.size_bytes.to_i }) %> · no drive has room</span>
310
+ </summary>
311
+ <table>
312
+ <thead><tr><th>Folder</th><th class="num">Size</th><th>Why</th><th>Seen</th></tr></thead>
313
+ <tbody>
314
+ <%- unplaced_all.each do |e| -%>
315
+ <tr><td><%= h e.folder_path %></td><td class="num"><%= bytes(e.size_bytes) %></td><td><%= h e.detail %></td><td><%= when_(e.seen_at) %></td></tr>
316
+ <%- end -%>
317
+ </tbody>
318
+ </table>
319
+ </details>
320
+ <%- end -%>
321
+ <%- end -%>
322
+ </div>
323
+
324
+ <%- if scrub_findings.empty? -%>
325
+ <p class="section-line"><strong>Scrub findings</strong> none. <code>easy_sync scrub</code> reads files back off a drive to catch rot.</p>
326
+ <%- else -%>
327
+ <h2>Scrub findings</h2>
328
+ <p class="meta">Files <code>easy_sync scrub</code> read back off a drive and found rotted, unreadable, or still bad after a refetch. Read-only: nothing here is ever deleted or copied by scrub itself.</p>
329
+ <div class="table-wrap">
330
+ <table>
331
+ <thead><tr><th>Drive</th><th>Path</th><th>Status</th><th>Next step</th></tr></thead>
332
+ <tbody>
333
+ <%- scrub_findings.each do |f| -%>
334
+ <tr>
335
+ <td class="drive"><%= h names.fetch(f.drive_serial, f.drive_serial) %></td>
336
+ <td><%= h f.label %></td>
337
+ <td><span class="status <%= f.status == 'ok' ? 'ok' : 'failed' %>"><%= h f.status %></span></td>
338
+ <td><%= h scrub_finding_phrase(f) %></td>
339
+ </tr>
340
+ <%- end -%>
341
+ </tbody>
342
+ </table>
343
+ </div>
344
+ <%- end -%>
345
+
346
+ <%- unless accepted_trips.empty? -%>
347
+ <h2>Accepted bulk changes</h2>
348
+ <p class="meta">Changes the tripwire stopped that were let through with <code>--accept-changes</code>, newest first.</p>
349
+ <div class="table-wrap">
350
+ <table>
351
+ <thead><tr><th>Folder</th><th class="num">Replaced</th><th class="num">Gone from NAS</th><th>Accepted</th></tr></thead>
352
+ <tbody>
353
+ <%- accepted_trips.each do |t| -%>
354
+ <tr><td><%= h t.folder_path %></td><td class="num"><%= count(t.replaced) %></td><td class="num"><%= count(t.missing) %></td><td class="when"><%= when_(t.accepted_at) %></td></tr>
355
+ <%- end -%>
356
+ </tbody>
357
+ </table>
358
+ </div>
359
+ <%- end -%>
360
+
361
+ <%- if pending.empty? -%>
362
+ <p class="section-line"><strong>Pending deletions</strong> none. Anything gone from the NAS waits <%= grace_days %> days here before it is deleted from a drive.</p>
363
+ <%- else -%>
364
+ <h2>Pending deletions</h2>
365
+ <p class="meta">Gone from the NAS, or the old copy left by a move. Deleted from the drive after <%= grace_days %> days and two syncs that confirm it (a move also waits for the new copy to sync). Anything that reappears drops off.</p>
366
+ <div class="table-wrap">
367
+ <table>
368
+ <thead><tr><th>Path</th><th>Kind</th><th>Since</th><th class="num">Runs</th><th>Deleted after</th></tr></thead>
369
+ <tbody>
370
+ <%- pending.each do |p| -%>
371
+ <tr>
372
+ <td><%= h pending_label(p) %></td>
373
+ <td><%= h pending_kind(p, names) %></td>
374
+ <td class="when"><%= when_(p.first_missing_at) %></td>
375
+ <td class="num"><%= p.reassigned? ? '—' : p.missing_runs %></td>
376
+ <td><%= expiry(p) %></td>
377
+ </tr>
378
+ <%- end -%>
379
+ </tbody>
380
+ </table>
381
+ </div>
382
+ <%- end -%>
383
+
384
+ <h2>Activity</h2>
385
+ <div class="table-wrap">
386
+ <%- if activity.empty? -%>
387
+ <p class="empty">Nothing recorded yet.</p>
388
+ <%- else -%>
389
+ <%- activity.each do |day, items| -%>
390
+ <p class="day"><%= h day %></p>
391
+ <ul class="feed">
392
+ <%- items.each do |i| -%>
393
+ <li class="kind-<%= i.kind %>">
394
+ <span class="time"><%= Time.parse(i.at).localtime.strftime('%H:%M') %></span>
395
+ <div>
396
+ <%- if i.items.empty? -%>
397
+ <span class="what"><%= h i.summary %></span>
398
+ <%- else -%>
399
+ <details class="group"><summary><span class="what"><%= h i.summary %></span></summary>
400
+ <ul>
401
+ <%- i.items.each do |line| -%>
402
+ <li><%= h line %></li>
403
+ <%- end -%>
404
+ </ul>
405
+ </details>
406
+ <%- end -%>
407
+ <%- if i.note && !i.note.empty? -%>
408
+ <div class="note"><%= h i.note %></div>
409
+ <%- end -%>
410
+ </div>
411
+ </li>
412
+ <%- end -%>
413
+ </ul>
414
+ <%- end -%>
415
+ <%- end -%>
416
+ </div>
417
+ </main>
418
+ </body>
419
+ </html>
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module EasySync
2
- VERSION = '0.0.5'
4
+ VERSION = '2.0.0'
3
5
  end
data/lib/easy_sync.rb CHANGED
@@ -1,4 +1,8 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require_relative 'easy_sync/version'
2
- require_relative 'easy_sync/sync_runner'
3
- require_relative 'easy_sync/rsync'
4
- require_relative 'easy_sync/sync_logger'
4
+ require_relative 'easy_sync/error'
5
+ require_relative 'easy_sync/shell'
6
+ require_relative 'easy_sync/config'
7
+ require_relative 'easy_sync/jbod'
8
+ require_relative 'easy_sync/cli'
metadata CHANGED
@@ -1,72 +1,87 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: easy_sync
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.5
4
+ version: 2.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Carlos Espejo
8
- autorequire:
9
8
  bindir: bin
10
9
  cert_chain: []
11
- date: 2014-12-28 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
- name: bundler
13
+ name: sqlite3
15
14
  requirement: !ruby/object:Gem::Requirement
16
15
  requirements:
17
16
  - - "~>"
18
17
  - !ruby/object:Gem::Version
19
- version: '1.5'
20
- type: :development
18
+ version: '2.9'
19
+ type: :runtime
21
20
  prerelease: false
22
21
  version_requirements: !ruby/object:Gem::Requirement
23
22
  requirements:
24
23
  - - "~>"
25
24
  - !ruby/object:Gem::Version
26
- version: '1.5'
25
+ version: '2.9'
27
26
  - !ruby/object:Gem::Dependency
28
- name: rake
27
+ name: logger
29
28
  requirement: !ruby/object:Gem::Requirement
30
29
  requirements:
31
- - - ">="
30
+ - - "~>"
32
31
  - !ruby/object:Gem::Version
33
- version: '0'
34
- type: :development
32
+ version: '1.6'
33
+ type: :runtime
35
34
  prerelease: false
36
35
  version_requirements: !ruby/object:Gem::Requirement
37
36
  requirements:
38
- - - ">="
37
+ - - "~>"
39
38
  - !ruby/object:Gem::Version
40
- version: '0'
39
+ version: '1.6'
41
40
  - !ruby/object:Gem::Dependency
42
- name: guard-minitest
41
+ name: fiddle
43
42
  requirement: !ruby/object:Gem::Requirement
44
43
  requirements:
45
- - - ">="
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '1.1'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
46
52
  - !ruby/object:Gem::Version
47
- version: '0'
53
+ version: '1.1'
54
+ - !ruby/object:Gem::Dependency
55
+ name: rake
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '13.0'
48
61
  type: :development
49
62
  prerelease: false
50
63
  version_requirements: !ruby/object:Gem::Requirement
51
64
  requirements:
52
- - - ">="
65
+ - - "~>"
53
66
  - !ruby/object:Gem::Version
54
- version: '0'
67
+ version: '13.0'
55
68
  - !ruby/object:Gem::Dependency
56
- name: terminal-notifier-guard
69
+ name: rspec
57
70
  requirement: !ruby/object:Gem::Requirement
58
71
  requirements:
59
- - - ">="
72
+ - - "~>"
60
73
  - !ruby/object:Gem::Version
61
- version: '0'
74
+ version: '3.13'
62
75
  type: :development
63
76
  prerelease: false
64
77
  version_requirements: !ruby/object:Gem::Requirement
65
78
  requirements:
66
- - - ">="
79
+ - - "~>"
67
80
  - !ruby/object:Gem::Version
68
- version: '0'
69
- description:
81
+ version: '3.13'
82
+ description: Mirrors each folder of your NAS shares onto one of several independently
83
+ mounted drives, tracks where everything lives in a SQLite manifest with a grace
84
+ period before deletions, and writes an HTML status dashboard with SMART health.
70
85
  email:
71
86
  - carlosespejo@gmail.com
72
87
  executables:
@@ -75,29 +90,59 @@ extensions: []
75
90
  extra_rdoc_files: []
76
91
  files:
77
92
  - ".gitignore"
93
+ - ".rspec"
94
+ - ".ruby-version"
95
+ - CHANGELOG.md
96
+ - CLAUDE.md
78
97
  - Gemfile
79
- - Gemfile.lock
80
- - Guardfile
81
98
  - LICENSE
82
99
  - README.md
83
100
  - Rakefile
84
101
  - bin/easy_sync
102
+ - docs/fine-placement.md
103
+ - docs/hardware-notes.md
104
+ - docs/integrity-scan.md
105
+ - docs/parallel-scrub.md
106
+ - docs/performance.md
107
+ - docs/tripwire.md
85
108
  - easy_sync.gemspec
86
109
  - lib/easy_sync.rb
87
110
  - lib/easy_sync/cli.rb
88
- - lib/easy_sync/rsync.rb
89
- - lib/easy_sync/sync_logger.rb
90
- - lib/easy_sync/sync_runner.rb
111
+ - lib/easy_sync/config.rb
112
+ - lib/easy_sync/error.rb
113
+ - lib/easy_sync/jbod.rb
114
+ - lib/easy_sync/jbod/backblaze.rb
115
+ - lib/easy_sync/jbod/benchmarker.rb
116
+ - lib/easy_sync/jbod/cleaner.rb
117
+ - lib/easy_sync/jbod/dashboard.rb
118
+ - lib/easy_sync/jbod/keep_awake.rb
119
+ - lib/easy_sync/jbod/manifest.rb
120
+ - lib/easy_sync/jbod/mirror.rb
121
+ - lib/easy_sync/jbod/models.rb
122
+ - lib/easy_sync/jbod/page_cache.rb
123
+ - lib/easy_sync/jbod/placement.rb
124
+ - lib/easy_sync/jbod/planner.rb
125
+ - lib/easy_sync/jbod/purger.rb
126
+ - lib/easy_sync/jbod/restorer.rb
127
+ - lib/easy_sync/jbod/run_lock.rb
128
+ - lib/easy_sync/jbod/run_log.rb
129
+ - lib/easy_sync/jbod/runner.rb
130
+ - lib/easy_sync/jbod/scrub_pool.rb
131
+ - lib/easy_sync/jbod/scrubber.rb
132
+ - lib/easy_sync/jbod/share_scan.rb
133
+ - lib/easy_sync/jbod/sync_eta.rb
134
+ - lib/easy_sync/jbod/tripwire.rb
135
+ - lib/easy_sync/jbod/volume_info.rb
136
+ - lib/easy_sync/shell.rb
137
+ - lib/easy_sync/templates/dashboard.html.erb
91
138
  - lib/easy_sync/version.rb
92
- - spec/easy_sync/rsync_spec.rb
93
- - spec/easy_sync/sync_runner_spec.rb
94
- - spec/spec_helper.rb
95
- - spec/support.rb
96
139
  homepage: https://github.com/CarlosEspejo/easy_sync
97
140
  licenses:
98
141
  - MIT
99
- metadata: {}
100
- post_install_message:
142
+ metadata:
143
+ changelog_uri: https://github.com/CarlosEspejo/easy_sync/blob/main/CHANGELOG.md
144
+ platform_note: 'macOS only (10.13 High Sierra or later): relies on diskutil and
145
+ caffeinate'
101
146
  rdoc_options: []
102
147
  require_paths:
103
148
  - lib
@@ -105,20 +150,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
105
150
  requirements:
106
151
  - - ">="
107
152
  - !ruby/object:Gem::Version
108
- version: '0'
153
+ version: '3.3'
109
154
  required_rubygems_version: !ruby/object:Gem::Requirement
110
155
  requirements:
111
156
  - - ">="
112
157
  - !ruby/object:Gem::Version
113
158
  version: '0'
114
159
  requirements: []
115
- rubyforge_project:
116
- rubygems_version: 2.4.5
117
- signing_key:
160
+ rubygems_version: 4.0.20
118
161
  specification_version: 4
119
- summary: Ruby wrapper around rsync to easily create incremental backups.
120
- test_files:
121
- - spec/easy_sync/rsync_spec.rb
122
- - spec/easy_sync/sync_runner_spec.rb
123
- - spec/spec_helper.rb
124
- - spec/support.rb
162
+ summary: Folder-level rsync backups from a NAS onto a set of independent (JBOD) drives.
163
+ test_files: []