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.
- checksums.yaml +5 -5
- data/.gitignore +8 -0
- data/.rspec +2 -0
- data/.ruby-version +1 -0
- data/CHANGELOG.md +85 -0
- data/CLAUDE.md +133 -0
- data/Gemfile +2 -0
- data/README.md +607 -21
- data/Rakefile +6 -8
- data/bin/easy_sync +8 -1
- data/docs/fine-placement.md +174 -0
- data/docs/hardware-notes.md +71 -0
- data/docs/integrity-scan.md +435 -0
- data/docs/parallel-scrub.md +280 -0
- data/docs/performance.md +146 -0
- data/docs/tripwire.md +232 -0
- data/easy_sync.gemspec +25 -16
- data/lib/easy_sync/cli.rb +1246 -0
- data/lib/easy_sync/config.rb +202 -0
- data/lib/easy_sync/error.rb +5 -0
- data/lib/easy_sync/jbod/backblaze.rb +113 -0
- data/lib/easy_sync/jbod/benchmarker.rb +149 -0
- data/lib/easy_sync/jbod/cleaner.rb +93 -0
- data/lib/easy_sync/jbod/dashboard.rb +611 -0
- data/lib/easy_sync/jbod/keep_awake.rb +33 -0
- data/lib/easy_sync/jbod/manifest.rb +1068 -0
- data/lib/easy_sync/jbod/mirror.rb +204 -0
- data/lib/easy_sync/jbod/models.rb +165 -0
- data/lib/easy_sync/jbod/page_cache.rb +56 -0
- data/lib/easy_sync/jbod/placement.rb +71 -0
- data/lib/easy_sync/jbod/planner.rb +76 -0
- data/lib/easy_sync/jbod/purger.rb +161 -0
- data/lib/easy_sync/jbod/restorer.rb +142 -0
- data/lib/easy_sync/jbod/run_lock.rb +98 -0
- data/lib/easy_sync/jbod/run_log.rb +57 -0
- data/lib/easy_sync/jbod/runner.rb +615 -0
- data/lib/easy_sync/jbod/scrub_pool.rb +136 -0
- data/lib/easy_sync/jbod/scrubber.rb +321 -0
- data/lib/easy_sync/jbod/share_scan.rb +26 -0
- data/lib/easy_sync/jbod/sync_eta.rb +68 -0
- data/lib/easy_sync/jbod/tripwire.rb +80 -0
- data/lib/easy_sync/jbod/volume_info.rb +283 -0
- data/lib/easy_sync/jbod.rb +35 -0
- data/lib/easy_sync/shell.rb +62 -0
- data/lib/easy_sync/templates/dashboard.html.erb +419 -0
- data/lib/easy_sync/version.rb +3 -1
- data/lib/easy_sync.rb +7 -3
- metadata +84 -45
- data/Gemfile.lock +0 -55
- data/Guardfile +0 -28
- data/lib/easy_sync/rsync.rb +0 -60
- data/lib/easy_sync/sync_logger.rb +0 -20
- data/lib/easy_sync/sync_runner.rb +0 -44
- data/spec/easy_sync/rsync_spec.rb +0 -25
- data/spec/easy_sync/sync_runner_spec.rb +0 -54
- data/spec/spec_helper.rb +0 -33
- 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/<drive></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>
|
data/lib/easy_sync/version.rb
CHANGED
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/
|
|
3
|
-
require_relative 'easy_sync/
|
|
4
|
-
require_relative 'easy_sync/
|
|
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
|
|
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:
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
12
11
|
dependencies:
|
|
13
12
|
- !ruby/object:Gem::Dependency
|
|
14
|
-
name:
|
|
13
|
+
name: sqlite3
|
|
15
14
|
requirement: !ruby/object:Gem::Requirement
|
|
16
15
|
requirements:
|
|
17
16
|
- - "~>"
|
|
18
17
|
- !ruby/object:Gem::Version
|
|
19
|
-
version: '
|
|
20
|
-
type: :
|
|
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: '
|
|
25
|
+
version: '2.9'
|
|
27
26
|
- !ruby/object:Gem::Dependency
|
|
28
|
-
name:
|
|
27
|
+
name: logger
|
|
29
28
|
requirement: !ruby/object:Gem::Requirement
|
|
30
29
|
requirements:
|
|
31
|
-
- - "
|
|
30
|
+
- - "~>"
|
|
32
31
|
- !ruby/object:Gem::Version
|
|
33
|
-
version: '
|
|
34
|
-
type: :
|
|
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: '
|
|
39
|
+
version: '1.6'
|
|
41
40
|
- !ruby/object:Gem::Dependency
|
|
42
|
-
name:
|
|
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: '
|
|
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:
|
|
69
|
+
name: rspec
|
|
57
70
|
requirement: !ruby/object:Gem::Requirement
|
|
58
71
|
requirements:
|
|
59
|
-
- - "
|
|
72
|
+
- - "~>"
|
|
60
73
|
- !ruby/object:Gem::Version
|
|
61
|
-
version: '
|
|
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: '
|
|
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/
|
|
89
|
-
- lib/easy_sync/
|
|
90
|
-
- lib/easy_sync/
|
|
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
|
-
|
|
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: '
|
|
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
|
-
|
|
116
|
-
rubygems_version: 2.4.5
|
|
117
|
-
signing_key:
|
|
160
|
+
rubygems_version: 4.0.20
|
|
118
161
|
specification_version: 4
|
|
119
|
-
summary:
|
|
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: []
|