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,611 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'erb'
|
|
4
|
+
require 'fileutils'
|
|
5
|
+
require 'time'
|
|
6
|
+
|
|
7
|
+
module EasySync
|
|
8
|
+
module Jbod
|
|
9
|
+
# Renders the static HTML status report from the manifest.
|
|
10
|
+
class Dashboard
|
|
11
|
+
TEMPLATE = File.expand_path('../templates/dashboard.html.erb', __dir__)
|
|
12
|
+
|
|
13
|
+
DriveView = Struct.new(:drive, :mounted, :mount_point, :capacity_bytes, :used_bytes, :free_bytes,
|
|
14
|
+
:used_fraction, :level, :health, :folders, keyword_init: true)
|
|
15
|
+
|
|
16
|
+
# Tile colour comes from SMART health only. Fullness is shown as a number;
|
|
17
|
+
# a JBOD drive at 97% is healthy by design and must not look like a problem.
|
|
18
|
+
# 'degraded_stable' (nonzero reallocated sectors that haven't grown since
|
|
19
|
+
# the last verified checkpoint) gets its own :stable level, distinct from
|
|
20
|
+
# :warning, so old non-progressing wear doesn't read as an active failure.
|
|
21
|
+
HEALTH_LEVELS = { 'ok' => :ok, 'degraded_stable' => :stable, 'warning' => :warning, 'failing' => :critical }.freeze
|
|
22
|
+
|
|
23
|
+
attr_reader :manifest, :grace_days
|
|
24
|
+
|
|
25
|
+
def initialize(manifest, grace_days: 7, scrub_stale_days: 30, clock: Time, mount_root: '/Volumes',
|
|
26
|
+
backblaze_dir: Backblaze::DATA_DIR)
|
|
27
|
+
@manifest = manifest
|
|
28
|
+
@mount_root = mount_root
|
|
29
|
+
@backblaze_dir = backblaze_dir
|
|
30
|
+
@grace_days = grace_days
|
|
31
|
+
@scrub_stale_days = scrub_stale_days
|
|
32
|
+
@clock = clock
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# +mounted+ is the list of MountedDrive structs from the current run;
|
|
36
|
+
# drives not in it are rendered with their last known numbers.
|
|
37
|
+
# +source_status+ maps folder_path => :present | :missing for folders seen on the NAS.
|
|
38
|
+
# +running+ is the RunLock::Status of whatever holds the run lock (sync,
|
|
39
|
+
# scrub, clean, or restore all share it), or nil when nothing is
|
|
40
|
+
# running. An ETA is only estimated for a real sync; the others just
|
|
41
|
+
# say how long they've been going (see #other_running_line).
|
|
42
|
+
def render(mounted: [], source_status: {}, running: nil)
|
|
43
|
+
@running = running
|
|
44
|
+
by_serial = mounted.to_h { |m| [m.serial_number, m] }
|
|
45
|
+
drives = manifest.drives.map { |d| drive_view(d, by_serial[d.serial_number]) }
|
|
46
|
+
runs = manifest.run_summaries(limit: RUNS_SHOWN)
|
|
47
|
+
inventory = manifest.source_inventory
|
|
48
|
+
folders = manifest.folders
|
|
49
|
+
names = manifest.drives(include_retired: true).to_h { |d| [d.serial_number, d.retired? ? "#{d.friendly_name} (retired)" : d.friendly_name] }
|
|
50
|
+
scrub_findings = manifest.scrub_findings
|
|
51
|
+
backblaze = Backblaze.read(@backblaze_dir)
|
|
52
|
+
@backblaze_installed = !backblaze.nil?
|
|
53
|
+
uploads = backblaze&.drive_states(manifest.drives, mounted: by_serial, mount_root: @mount_root,
|
|
54
|
+
last_copied_at: manifest.last_copied_at)
|
|
55
|
+
trips = current_trips(runs)
|
|
56
|
+
issues = issues(drives: drives, folders: folders, inventory: inventory, source_status: source_status,
|
|
57
|
+
runs: runs, scrub_findings: scrub_findings, trips: trips)
|
|
58
|
+
locals = {
|
|
59
|
+
drives: drives,
|
|
60
|
+
folders: folders,
|
|
61
|
+
names: names,
|
|
62
|
+
issues: issues,
|
|
63
|
+
verdict: verdict(issues, inventory, folders, source_status, trips),
|
|
64
|
+
trips: trips,
|
|
65
|
+
accepted_trips: manifest.accepted_trips(limit: ACCEPTED_TRIPS_SHOWN),
|
|
66
|
+
activity: activity_days(history_groups(manifest.history(limit: HISTORY_ROWS)),
|
|
67
|
+
deletion_groups(manifest.deletions(limit: HISTORY_ROWS)), runs, names),
|
|
68
|
+
runs: runs,
|
|
69
|
+
latest_notable: runs.empty? ? [] : manifest.notable_sync_runs(runs.first.run_started_at),
|
|
70
|
+
generated_at: @clock.now,
|
|
71
|
+
total_capacity_bytes: drives.sum { |d| d.capacity_bytes.to_i },
|
|
72
|
+
total_free_bytes: drives.sum { |d| d.free_bytes.to_i },
|
|
73
|
+
source_status: source_status,
|
|
74
|
+
pending: manifest.pending_deletions,
|
|
75
|
+
inventory: inventory,
|
|
76
|
+
scrub_findings: scrub_findings,
|
|
77
|
+
uploads: uploads,
|
|
78
|
+
running: running,
|
|
79
|
+
eta: running&.kind == 'sync' ? SyncEta.for(manifest, running.started_at) : nil
|
|
80
|
+
}
|
|
81
|
+
scope = binding
|
|
82
|
+
locals.each { |name, value| scope.local_variable_set(name, value) }
|
|
83
|
+
ERB.new(File.read(TEMPLATE, encoding: 'UTF-8'), trim_mode: '-').result(scope)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def write(path, **)
|
|
87
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
88
|
+
File.write(path, render(**))
|
|
89
|
+
path
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def drive_view(drive, mounted)
|
|
93
|
+
capacity = mounted ? mounted.capacity_bytes : drive.capacity_bytes
|
|
94
|
+
used = mounted ? mounted.used_bytes : drive.last_used_bytes
|
|
95
|
+
free = mounted ? mounted.free_bytes : drive.last_free_bytes
|
|
96
|
+
fraction = used && capacity.to_i.positive? ? used.to_f / capacity : nil
|
|
97
|
+
DriveView.new(drive: drive, mounted: !mounted.nil?, mount_point: mounted&.mount_point,
|
|
98
|
+
capacity_bytes: capacity, used_bytes: used, free_bytes: free,
|
|
99
|
+
used_fraction: fraction, level: HEALTH_LEVELS.fetch(drive.smart_status, :unknown),
|
|
100
|
+
health: drive.smart_status || 'unknown',
|
|
101
|
+
folders: manifest.folders_on(drive.serial_number))
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
HEALTH_LABELS = { 'ok' => 'SMART ok', 'degraded_stable' => 'SMART: historical wear, stable',
|
|
105
|
+
'warning' => 'SMART: starting to fail', 'failing' => 'SMART: FAILING',
|
|
106
|
+
'unknown' => 'SMART n/a' }.freeze
|
|
107
|
+
|
|
108
|
+
def health_label(status) = HEALTH_LABELS.fetch(status, 'SMART n/a')
|
|
109
|
+
|
|
110
|
+
# A healthy tile shows only its temperature; the sector counters matter
|
|
111
|
+
# only once something is wrong (they stay in the tooltip otherwise).
|
|
112
|
+
def health_line(view)
|
|
113
|
+
temp = view.drive.smart_detail.to_s[/\d+°C/] if view.health == 'ok'
|
|
114
|
+
[health_label(view.health), temp].compact.join(' · ')
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def health_detail_shown?(view) = %w[degraded_stable warning failing].include?(view.health) && view.drive.smart_detail
|
|
118
|
+
|
|
119
|
+
# Renders a SyncEta::Estimate the same way `status` phrases it, for the
|
|
120
|
+
# banner shown while a sync is running. nil (nothing running, or
|
|
121
|
+
# nothing left to estimate) means the caller shows nothing at all.
|
|
122
|
+
def eta_line(eta)
|
|
123
|
+
case eta&.status
|
|
124
|
+
when nil then nil
|
|
125
|
+
when :waiting_for_first_folder
|
|
126
|
+
'Estimating time remaining: still measuring/placing folders, or waiting on a large first copy to finish...'
|
|
127
|
+
when :waiting_for_first_transfer
|
|
128
|
+
"#{eta.never_synced_count} folder#{'s' if eta.never_synced_count != 1} never synced (#{bytes(eta.never_synced_bytes)}); " \
|
|
129
|
+
'still waiting for one to finish before estimating their time.'
|
|
130
|
+
when :estimate
|
|
131
|
+
"About #{Placement.format_duration(eta.seconds)} remaining (#{eta.never_synced_count} folder#{'s' if eta.never_synced_count != 1} never synced, " \
|
|
132
|
+
"#{eta.to_reverify} to re-verify) - rough estimate, NAS/network speed varies."
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# For scrub/clean/restore holding the shared lock: none of those have a
|
|
137
|
+
# sync-style ETA, so just say how long the run has been going.
|
|
138
|
+
def other_running_line(running)
|
|
139
|
+
"#{running.kind.capitalize} in progress (started #{Placement.format_duration(@clock.now - running.started_at)} ago)."
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# The mount path is only news when macOS mounted the drive somewhere
|
|
143
|
+
# other than under its own name (e.g. "backup-02-6tb 1").
|
|
144
|
+
def unexpected_mount?(view) = view.mounted && File.basename(view.mount_point) != view.drive.friendly_name
|
|
145
|
+
|
|
146
|
+
MODEL_SHOWN = 20 # branded_model past this wraps the tile onto two lines
|
|
147
|
+
|
|
148
|
+
# [text to show, full string for a title attribute (nil when nothing
|
|
149
|
+
# was cut, so no tooltip appears for text that's already complete)].
|
|
150
|
+
def serial_line(drive)
|
|
151
|
+
model = drive.branded_model
|
|
152
|
+
return [drive.serial_number, nil] unless model
|
|
153
|
+
|
|
154
|
+
full = "#{drive.serial_number} · #{model}"
|
|
155
|
+
return [full, nil] if model.length <= MODEL_SHOWN
|
|
156
|
+
|
|
157
|
+
["#{drive.serial_number} · #{model[0, MODEL_SHOWN - 1].rstrip}…", full]
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Inventory rows for one share, by state.
|
|
161
|
+
def inventory_for(inventory, share)
|
|
162
|
+
rows = inventory.select { |e| e.share == share }
|
|
163
|
+
{ total: rows.size, placed: rows.count { |e| e.state == 'placed' },
|
|
164
|
+
unplaced: rows.select { |e| e.state == 'unplaced' }, empty: rows.count { |e| e.state == 'empty' },
|
|
165
|
+
total_bytes: rows.sum { |e| e.size_bytes.to_i } }
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# -- status: what needs you ------------------------------------------
|
|
169
|
+
|
|
170
|
+
# Backblaze Personal drops a drive from the current backup once it has
|
|
171
|
+
# not been connected for 30 days (history keeps it for a year). Warn
|
|
172
|
+
# with time to act; only when Backblaze is installed, since without it
|
|
173
|
+
# a drive left disconnected is the normal state between syncs.
|
|
174
|
+
BACKBLAZE_WARN_DAYS = 21
|
|
175
|
+
BACKBLAZE_DROP_DAYS = 30
|
|
176
|
+
STALE_SYNC_DAYS = 7
|
|
177
|
+
RUNS_SHOWN = 30
|
|
178
|
+
ACCEPTED_TRIPS_SHOWN = 10
|
|
179
|
+
|
|
180
|
+
# The tripwire's unaccepted trips from the latest run, unless a later
|
|
181
|
+
# run has synced since. A stopped run records no sync_runs, so its
|
|
182
|
+
# trips outrank the last run that did copy; a folder-only trip shares
|
|
183
|
+
# its run_started_at with the folders that did copy.
|
|
184
|
+
def current_trips(runs)
|
|
185
|
+
latest = manifest.latest_trips.reject(&:accepted?)
|
|
186
|
+
return [] if latest.empty?
|
|
187
|
+
return [] if runs.first && runs.first.run_started_at > latest.first.run_started_at
|
|
188
|
+
|
|
189
|
+
latest
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def run_stopped?(trips) = trips.any? { |t| t.scope == 'run' }
|
|
193
|
+
|
|
194
|
+
Issue = Struct.new(:level, :html, keyword_init: true)
|
|
195
|
+
Verdict = Struct.new(:level, :headline, keyword_init: true)
|
|
196
|
+
|
|
197
|
+
# Everything worth your attention, most serious first. Each is one line
|
|
198
|
+
# of trusted HTML (every interpolated value is escaped here).
|
|
199
|
+
def issues(drives:, folders:, inventory:, source_status:, runs:, scrub_findings:, trips: [])
|
|
200
|
+
list = []
|
|
201
|
+
if run_stopped?(trips)
|
|
202
|
+
list << Issue.new(level: :critical,
|
|
203
|
+
html: "<strong>The last sync was stopped by the tripwire</strong> #{when_(trips.first.run_started_at)}: " \
|
|
204
|
+
"#{count(trips.sum(&:changed))} existing files would have been overwritten or are gone from the NAS. " \
|
|
205
|
+
'Nothing was copied or purged. Check the NAS for ransomware first; if you made this change yourself, ' \
|
|
206
|
+
'run <code>easy_sync sync --accept-changes</code> (see Tripwire).')
|
|
207
|
+
elsif trips.any?
|
|
208
|
+
list << Issue.new(level: :critical,
|
|
209
|
+
html: "<strong>#{trips.size} folder#{'s' if trips.size != 1} held back by the tripwire</strong>: " \
|
|
210
|
+
'far more of their files would change than normal, so they were not copied. ' \
|
|
211
|
+
'Check them on the NAS (see Tripwire).')
|
|
212
|
+
end
|
|
213
|
+
unplaced = inventory.select { |e| e.state == 'unplaced' }
|
|
214
|
+
unless unplaced.empty?
|
|
215
|
+
list << Issue.new(level: :critical,
|
|
216
|
+
html: "<strong>#{count(unplaced.size)} folder#{'s' if unplaced.size != 1} " \
|
|
217
|
+
"(#{bytes(unplaced.sum { |e| e.size_bytes.to_i })}) on the NAS " \
|
|
218
|
+
"#{unplaced.size == 1 ? 'is' : 'are'} not backed up</strong>: no drive has room. " \
|
|
219
|
+
'Add or replace a drive, then sync again (list under Folders).')
|
|
220
|
+
end
|
|
221
|
+
drives.select { |d| %i[warning critical].include?(d.level) }.each do |d|
|
|
222
|
+
name = h(d.drive.friendly_name)
|
|
223
|
+
list << Issue.new(level: d.level,
|
|
224
|
+
html: "<strong>#{name} #{d.level == :critical ? 'is FAILING' : 'is starting to fail'}</strong>: " \
|
|
225
|
+
"SMART says #{h d.drive.smart_detail} (checked #{when_(d.drive.smart_checked_at)}). " \
|
|
226
|
+
"Register a new drive, then <code>easy_sync replace-drive #{name} --to NEW_NAME --copy</code>.")
|
|
227
|
+
end
|
|
228
|
+
drives.each do |d|
|
|
229
|
+
days = unseen_days(d)
|
|
230
|
+
next unless @backblaze_installed && days && days >= BACKBLAZE_WARN_DAYS
|
|
231
|
+
|
|
232
|
+
name = h(d.drive.friendly_name)
|
|
233
|
+
list << if days >= BACKBLAZE_DROP_DAYS
|
|
234
|
+
Issue.new(level: :critical,
|
|
235
|
+
html: "<strong>#{name} not connected for #{days} days</strong>: it has dropped out of " \
|
|
236
|
+
"Backblaze's current backup (history keeps it for a year). Connect it and let Backblaze catch up.")
|
|
237
|
+
else
|
|
238
|
+
Issue.new(level: :warning,
|
|
239
|
+
html: "<strong>#{name} not connected for #{days} days</strong>: Backblaze drops it from the " \
|
|
240
|
+
"current backup at #{BACKBLAZE_DROP_DAYS}. Connect it within #{BACKBLAZE_DROP_DAYS - days} days.")
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
bad = folders.reject { |f| NEUTRAL.include?(folder_status(f, source_status)) }
|
|
244
|
+
unless bad.empty?
|
|
245
|
+
list << Issue.new(level: :warning,
|
|
246
|
+
html: "<strong>#{count(bad.size)} folder#{'s' if bad.size != 1} not in a good state</strong> " \
|
|
247
|
+
'(failed, drive full, held back, missing on the NAS or not mounted): see Needs attention under Folders.')
|
|
248
|
+
end
|
|
249
|
+
latest = runs.first
|
|
250
|
+
if latest && latest.failed.to_i.positive?
|
|
251
|
+
list << Issue.new(level: :warning,
|
|
252
|
+
html: "<strong>The last sync had #{latest.failed} failure#{'s' if latest.failed != 1}</strong>; " \
|
|
253
|
+
'they are retried on the next sync (see Latest sync).')
|
|
254
|
+
end
|
|
255
|
+
if latest && days_ago(latest.run_started_at) >= STALE_SYNC_DAYS
|
|
256
|
+
list << Issue.new(level: :warning,
|
|
257
|
+
html: "<strong>No sync for #{days_ago(latest.run_started_at)} days</strong>: run <code>easy_sync sync</code>.")
|
|
258
|
+
end
|
|
259
|
+
unless scrub_findings.empty?
|
|
260
|
+
list << Issue.new(level: :critical,
|
|
261
|
+
html: "<strong>#{count(scrub_findings.size)} file#{'s' if scrub_findings.size != 1} flagged by scrub</strong> " \
|
|
262
|
+
'(rot or read errors): see Scrub findings.')
|
|
263
|
+
end
|
|
264
|
+
overdue = drives.select { |d| scrub_overdue?(d) }
|
|
265
|
+
unless overdue.empty?
|
|
266
|
+
list << Issue.new(level: :warning,
|
|
267
|
+
html: "<strong>#{overdue.size} drive#{'s' if overdue.size != 1} overdue for a scrub</strong> " \
|
|
268
|
+
"(#{h overdue.map { |d| d.drive.friendly_name }.join(', ')}): <code>easy_sync scrub --all</code>.")
|
|
269
|
+
end
|
|
270
|
+
list.sort_by { |i| i.level == :critical ? 0 : 1 }
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def verdict(issues, inventory, folders, source_status, trips = [])
|
|
274
|
+
level = if issues.any? { |i| i.level == :critical } then :critical
|
|
275
|
+
elsif issues.any? then :warning
|
|
276
|
+
else :ok
|
|
277
|
+
end
|
|
278
|
+
total = inventory.empty? ? folders.size : inventory.count { |e| e.state != 'empty' }
|
|
279
|
+
unplaced = inventory.count { |e| e.state == 'unplaced' }
|
|
280
|
+
# Backed up = copied at least once. A folder whose drive was simply
|
|
281
|
+
# offline for the last sync still is; one never copied is not.
|
|
282
|
+
backed = [folders.count(&:last_synced_at), total].min
|
|
283
|
+
waiting = unsynced_in(folders, source_status)
|
|
284
|
+
headline = if run_stopped?(trips) then "Sync stopped: #{count(trips.sum(&:changed))} files would change on the NAS side"
|
|
285
|
+
elsif total.zero? then 'Nothing backed up yet'
|
|
286
|
+
elsif unplaced.positive? then "#{count(unplaced)} of #{count(total)} folders are not backed up"
|
|
287
|
+
elsif backed == total then "All #{count(total)} folders backed up"
|
|
288
|
+
elsif waiting.positive? then "#{count(backed)} of #{count(total)} folders backed up, #{count(waiting)} waiting for their first copy"
|
|
289
|
+
else "#{count(backed)} of #{count(total)} folders backed up"
|
|
290
|
+
end
|
|
291
|
+
Verdict.new(level: level, headline: headline)
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
# Days since a drive was last connected; nil while it is connected now.
|
|
295
|
+
def unseen_days(view)
|
|
296
|
+
return nil if view.mounted || view.drive.last_seen_at.nil?
|
|
297
|
+
|
|
298
|
+
days_ago(view.drive.last_seen_at)
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
def seen_level(view)
|
|
302
|
+
days = unseen_days(view) or return nil
|
|
303
|
+
return nil unless @backblaze_installed
|
|
304
|
+
return 'critical' if days >= BACKBLAZE_DROP_DAYS
|
|
305
|
+
|
|
306
|
+
'warning' if days >= BACKBLAZE_WARN_DAYS
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
# -- activity feed ---------------------------------------------------
|
|
310
|
+
|
|
311
|
+
ActivityItem = Struct.new(:at, :kind, :summary, :note, :items, keyword_init: true)
|
|
312
|
+
|
|
313
|
+
# Sync runs, placement changes and deletions in one newest-first feed,
|
|
314
|
+
# grouped by local day: [[day_label, [ActivityItem, ...]], ...].
|
|
315
|
+
def activity_days(history, deletions, runs, names)
|
|
316
|
+
items = runs.map do |r|
|
|
317
|
+
summary = in_progress?(r, runs) ? "Sync in progress: #{count(r.folders)} folder#{'s' if r.folders != 1} checked so far" : run_line(r)
|
|
318
|
+
ActivityItem.new(at: r.run_started_at, kind: 'sync', summary: summary, note: nil, items: [])
|
|
319
|
+
end
|
|
320
|
+
# A removal is already in the feed as its deletion from the drive.
|
|
321
|
+
items += history.reject { |g| g.event == 'removed' }.map do |g|
|
|
322
|
+
ActivityItem.new(at: g.at, kind: g.event, summary: history_summary(g, names), note: readable_note(g.note, names),
|
|
323
|
+
items: g.items.size == 1 ? [] : g.items.map { |e| history_item(e, g, names) })
|
|
324
|
+
end
|
|
325
|
+
items += deletions.map do |g|
|
|
326
|
+
drive = names.fetch(g.drive_serial, g.drive_serial)
|
|
327
|
+
single = g.items.size == 1
|
|
328
|
+
ActivityItem.new(at: g.at, kind: 'deleted',
|
|
329
|
+
summary: "#{single ? deletion_label(g.items.first) : deletion_summary(g)} deleted from #{drive}",
|
|
330
|
+
note: single ? deletion_reason(g.items.first) : nil,
|
|
331
|
+
items: single ? [] : g.items.map { |d| "#{deletion_label(d)} (#{deletion_reason(d)})" })
|
|
332
|
+
end
|
|
333
|
+
items.sort_by(&:at).reverse.first(ACTIVITY_SHOWN).group_by { |i| day_label(i.at) }.to_a
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
ACTIVITY_SHOWN = 40
|
|
337
|
+
|
|
338
|
+
def run_line(run)
|
|
339
|
+
copied = run.copied.to_i.zero? ? 'nothing to copy' : "#{count(run.copied)} copied (#{bytes(run.bytes_transferred)})"
|
|
340
|
+
failed = run.failed.to_i.positive? ? ", #{run.failed} failed" : ''
|
|
341
|
+
"Sync: #{count(run.folders)} folder#{'s' if run.folders != 1} checked, #{copied}#{failed} in #{run_duration(run)}"
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
# -- template helpers ------------------------------------------------
|
|
345
|
+
|
|
346
|
+
# 2692 -> "2,692"
|
|
347
|
+
def count(n) = n.to_i.to_s.reverse.scan(/\d{1,3}/).join(',').reverse
|
|
348
|
+
|
|
349
|
+
# "today 20:18", "yesterday 08:50", "Sep 21 13:45" (or with the year
|
|
350
|
+
# when it isn't this year).
|
|
351
|
+
def relative_when(iso)
|
|
352
|
+
return 'never' if iso.nil? || iso.empty?
|
|
353
|
+
|
|
354
|
+
t = Time.parse(iso).localtime
|
|
355
|
+
"#{day_label(iso).sub(/\A(Today|Yesterday)\z/) { |w| w.downcase }} #{t.strftime('%H:%M')}"
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
def day_label(iso)
|
|
359
|
+
t = Time.parse(iso).localtime
|
|
360
|
+
today = @clock.now.localtime.to_date
|
|
361
|
+
return 'Today' if t.to_date == today
|
|
362
|
+
return 'Yesterday' if t.to_date == today - 1
|
|
363
|
+
|
|
364
|
+
t.year == today.year ? t.strftime('%a %b %-d') : t.strftime('%a %b %-d %Y')
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
def bytes(value) = Placement.format_bytes(value)
|
|
368
|
+
|
|
369
|
+
# Enough raw rows to fill GROUPS_SHOWN groups even when one bulk
|
|
370
|
+
# operation (a first placement, a reassign of dozens of folders)
|
|
371
|
+
# produced thousands of them.
|
|
372
|
+
HISTORY_ROWS = 5_000
|
|
373
|
+
GROUPS_SHOWN = 20
|
|
374
|
+
|
|
375
|
+
# Rows recorded in the same minute for the same reason, as one line: a
|
|
376
|
+
# bulk operation (a first placement across every drive, a reassign of
|
|
377
|
+
# dozens of folders) reads as one event instead of pushing everything
|
|
378
|
+
# else off the list. The note's trailing "(4.8 TB free)"-style detail
|
|
379
|
+
# differs row to row within one batch, so it is left out of the match.
|
|
380
|
+
HistoryGroup = Struct.new(:at, :event, :note, :items, keyword_init: true) do
|
|
381
|
+
def drive_serials = items.map(&:drive_serial).uniq
|
|
382
|
+
end
|
|
383
|
+
DeletionGroup = Struct.new(:at, :drive_serial, :items, keyword_init: true) do
|
|
384
|
+
def folders = items.count { |d| d.kind == 'folder' }
|
|
385
|
+
def files = items.size - folders
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
def history_groups(entries)
|
|
389
|
+
group_rows(entries, ->(e) { [minute(e.recorded_at), e.event, note_gist(e.note)] }).map do |g|
|
|
390
|
+
HistoryGroup.new(at: g.first.recorded_at, event: g.first.event,
|
|
391
|
+
note: g.size == 1 ? g.first.note : note_gist(g.first.note), items: g)
|
|
392
|
+
end
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
def deletion_groups(deletions)
|
|
396
|
+
group_rows(deletions, ->(d) { [minute(d.deleted_at), d.drive_serial] }).map do |g|
|
|
397
|
+
DeletionGroup.new(at: g.first.deleted_at, drive_serial: g.first.drive_serial, items: g)
|
|
398
|
+
end
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
# Newest first, like the rows themselves.
|
|
402
|
+
def group_rows(rows, key) = rows.group_by { |r| key.call(r) }.values.first(GROUPS_SHOWN)
|
|
403
|
+
|
|
404
|
+
def minute(iso) = iso.to_s[0, 16]
|
|
405
|
+
def note_gist(note) = note.to_s.sub(/\s*\([^()]*\)\z/, '')
|
|
406
|
+
|
|
407
|
+
EVENT_VERBS = { 'assigned' => 'placed on', 'reassigned' => 'moved to', 'removed' => 'deleted from',
|
|
408
|
+
'split' => 'split on' }.freeze
|
|
409
|
+
|
|
410
|
+
# "3 folders placed on backup-07-6tb" / "2,666 folders placed on 8
|
|
411
|
+
# drives", or the folder itself when alone.
|
|
412
|
+
def history_summary(group, names)
|
|
413
|
+
serials = group.drive_serials
|
|
414
|
+
where = serials.size == 1 ? names.fetch(serials.first, serials.first) : "#{serials.size} drives"
|
|
415
|
+
what = group.items.size == 1 ? group.items.first.folder_path : "#{group.items.size} folders"
|
|
416
|
+
"#{what} #{EVENT_VERBS.fetch(group.event, group.event)} #{where}"
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
def history_item(entry, group, names)
|
|
420
|
+
return entry.folder_path if group.drive_serials.size == 1
|
|
421
|
+
|
|
422
|
+
"#{entry.folder_path} → #{names.fetch(entry.drive_serial, entry.drive_serial)}"
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
def deletion_summary(group)
|
|
426
|
+
parts = []
|
|
427
|
+
parts << "#{group.folders} folder#{'s' if group.folders != 1}" if group.folders.positive?
|
|
428
|
+
parts << "#{group.files} file#{'s' if group.files != 1}" if group.files.positive?
|
|
429
|
+
parts.join(' and ')
|
|
430
|
+
end
|
|
431
|
+
|
|
432
|
+
def deletion_label(d) = d.kind == 'folder' ? "#{d.folder_path} (whole folder)" : "#{d.folder_path}/#{d.relative_path}"
|
|
433
|
+
|
|
434
|
+
# The old copy a move left behind is recorded with its drive's serial
|
|
435
|
+
# where a path would be (see PendingDeletion); it was never missing.
|
|
436
|
+
def moved_copy?(d) = d.kind == 'folder' && d.relative_path == d.drive_serial
|
|
437
|
+
|
|
438
|
+
def deletion_reason(d)
|
|
439
|
+
moved_copy?(d) ? "old copy, after the move on #{when_(d.first_missing_at)}" : "missing on the NAS since #{when_(d.first_missing_at)}"
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
# Notes written by earlier code name drives by serial ("moved from
|
|
443
|
+
# SN-EXAMPLE1"); show the drive's name instead, and timestamps the way the
|
|
444
|
+
# rest of the page does. A removal's note repeats "deleted from <drive>:",
|
|
445
|
+
# which the line itself already says.
|
|
446
|
+
def readable_note(note, names)
|
|
447
|
+
text = names.reduce(note.to_s) { |t, (serial, name)| t.gsub(serial, name) }
|
|
448
|
+
text = text.sub(/\Adeleted from [^:]+: /, '')
|
|
449
|
+
text.gsub(/\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ/) { |iso| when_(iso) }
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
# "2h 14m" from a run's start to its last folder finishing.
|
|
453
|
+
def run_duration(run)
|
|
454
|
+
return '—' unless run.last_finished_at
|
|
455
|
+
|
|
456
|
+
Placement.format_duration(Time.parse(run.last_finished_at) - Time.parse(run.run_started_at))
|
|
457
|
+
end
|
|
458
|
+
|
|
459
|
+
# The newest run is still going when a sync holds the lock and started
|
|
460
|
+
# no later than it (the lock is taken just before the run begins).
|
|
461
|
+
def in_progress?(run, runs)
|
|
462
|
+
@running&.kind == 'sync' && run.equal?(runs.first) &&
|
|
463
|
+
@running.started_at.utc.iso8601 <= run.run_started_at
|
|
464
|
+
end
|
|
465
|
+
|
|
466
|
+
def percent(fraction)
|
|
467
|
+
fraction.nil? ? '—' : format('%.0f%%', fraction * 100)
|
|
468
|
+
end
|
|
469
|
+
|
|
470
|
+
def when_(iso)
|
|
471
|
+
return 'never' if iso.nil? || iso.empty?
|
|
472
|
+
|
|
473
|
+
# (non-breaking space) between date and time: a table column
|
|
474
|
+
# narrower than the full string must not split it across two lines.
|
|
475
|
+
Time.parse(iso).localtime.strftime("%Y-%m-%d %H:%M")
|
|
476
|
+
rescue ArgumentError
|
|
477
|
+
iso
|
|
478
|
+
end
|
|
479
|
+
|
|
480
|
+
def h(text) = ERB::Util.html_escape(text.to_s)
|
|
481
|
+
|
|
482
|
+
# Groups folders by share (the first path segment): "tv" groups every
|
|
483
|
+
# "tv/<show>" plus the share's own root-files unit, or a share placed
|
|
484
|
+
# whole by an earlier build. Returns [[share, [folders...]], ...] in share order.
|
|
485
|
+
# +inventory+ adds shares that have nothing placed yet (nothing fit), so
|
|
486
|
+
# they still get a summary line.
|
|
487
|
+
def by_share(folders, inventory = [])
|
|
488
|
+
groups = folders.group_by { |f| f.folder_path.split('/').first }
|
|
489
|
+
inventory.each { |e| groups[e.share] ||= [] }
|
|
490
|
+
groups.sort_by(&:first)
|
|
491
|
+
end
|
|
492
|
+
|
|
493
|
+
def total_size(folders) = folders.sum { |f| f.size_bytes.to_i }
|
|
494
|
+
|
|
495
|
+
# Status label for a folder row, resolving the source-side state first.
|
|
496
|
+
def folder_status(folder, source_status)
|
|
497
|
+
case source_status[folder.folder_path]
|
|
498
|
+
when :missing then 'missing'
|
|
499
|
+
when :source_unavailable then 'skipped_source_unmounted'
|
|
500
|
+
else folder.last_sync_status || 'never'
|
|
501
|
+
end
|
|
502
|
+
end
|
|
503
|
+
|
|
504
|
+
STATUS_LABELS = {
|
|
505
|
+
'missing' => 'missing on NAS', 'skipped_source_unmounted' => 'share not mounted',
|
|
506
|
+
'skipped_unmounted' => 'drive not mounted', 'drive_full' => 'drive full',
|
|
507
|
+
'tripped' => 'held back by tripwire', 'skipped_check_failed' => 'check failed'
|
|
508
|
+
}.freeze
|
|
509
|
+
|
|
510
|
+
def status_label(status) = STATUS_LABELS.fetch(status, status)
|
|
511
|
+
|
|
512
|
+
# 'never' means assigned but not yet copied (mid-run, or an interrupted
|
|
513
|
+
# run): pending work, not a problem, so it never lands in Needs attention.
|
|
514
|
+
NEUTRAL = %w[ok never].freeze
|
|
515
|
+
|
|
516
|
+
def problems_in(folders, source_status)
|
|
517
|
+
folders.count { |f| !NEUTRAL.include?(folder_status(f, source_status)) }
|
|
518
|
+
end
|
|
519
|
+
|
|
520
|
+
def unsynced_in(folders, source_status)
|
|
521
|
+
folders.count { |f| folder_status(f, source_status) == 'never' }
|
|
522
|
+
end
|
|
523
|
+
|
|
524
|
+
# The folder table body, shared by the "needs attention" list and each
|
|
525
|
+
# per-share group. Rows needing attention sort first within a group.
|
|
526
|
+
def folder_rows(folders, source_status, pending, names)
|
|
527
|
+
rows = folders.sort_by { |f| [NEUTRAL.include?(folder_status(f, source_status)) ? 1 : 0, f.folder_path] }.map do |f|
|
|
528
|
+
status = folder_status(f, source_status)
|
|
529
|
+
whole = pending.find { |p| p.whole_folder? && p.folder_path == f.folder_path }
|
|
530
|
+
note = whole ? %(<br><small style="color:var(--muted)">deleted from drive after #{expiry(whole)}</small>) : ''
|
|
531
|
+
"<tr><td>#{h f.folder_path}</td><td class=\"drive\">#{h names.fetch(f.drive_serial, f.drive_serial)}</td>" \
|
|
532
|
+
"<td class=\"num\">#{bytes(f.size_bytes)}</td><td>#{when_(f.last_synced_at)}</td>" \
|
|
533
|
+
"<td><span class=\"status #{status}\">#{h status_label(status)}</span>#{note}</td>" \
|
|
534
|
+
"<td>#{when_(f.assigned_at)}</td></tr>"
|
|
535
|
+
end
|
|
536
|
+
'<table><thead><tr><th>Folder</th><th>Drive</th><th class="num">Size</th><th>Last synced</th>' \
|
|
537
|
+
"<th>Status</th><th>Assigned</th></tr></thead><tbody>#{rows.join}</tbody></table>"
|
|
538
|
+
end
|
|
539
|
+
|
|
540
|
+
# A 'reassigned' row also needs its folder verified synced to its new
|
|
541
|
+
# drive before it's actually eligible (see Purger#ready?); the date
|
|
542
|
+
# alone is only the earliest it could happen.
|
|
543
|
+
def expiry(pending)
|
|
544
|
+
date = pending.expires_at(grace_days).localtime.strftime('%Y-%m-%d')
|
|
545
|
+
return date unless pending.reassigned?
|
|
546
|
+
|
|
547
|
+
manifest.folder(pending.folder_path)&.last_sync_status == 'ok' ? date : "#{date} (once resynced)"
|
|
548
|
+
end
|
|
549
|
+
|
|
550
|
+
def pending_label(p)
|
|
551
|
+
p.whole_folder? ? "#{p.folder_path} (whole folder)" : "#{p.folder_path}/#{p.relative_path}"
|
|
552
|
+
end
|
|
553
|
+
|
|
554
|
+
def pending_kind(p, names)
|
|
555
|
+
p.reassigned? ? "moved off #{names.fetch(p.drive_serial, p.drive_serial)}" : p.kind
|
|
556
|
+
end
|
|
557
|
+
|
|
558
|
+
# The header's Backblaze clause: whether the drives can be powered off
|
|
559
|
+
# without leaving anything waiting to upload.
|
|
560
|
+
def backblaze_summary(uploads)
|
|
561
|
+
pending = uploads.values.count { |u| !u.done? }
|
|
562
|
+
pending.zero? ? 'Backblaze up to date' : "Backblaze: #{pending} drive#{'s' if pending != 1} not up to date"
|
|
563
|
+
end
|
|
564
|
+
|
|
565
|
+
# "scrubbing now", "scrubbed N days ago", or "never scrubbed", shown on
|
|
566
|
+
# every drive tile regardless of whether it's overdue.
|
|
567
|
+
def scrub_status_line(view)
|
|
568
|
+
return 'scrubbing now' if scrubbing?(view)
|
|
569
|
+
|
|
570
|
+
through = manifest.scrubbed_through(view.drive.serial_number)
|
|
571
|
+
return 'never scrubbed' unless through
|
|
572
|
+
|
|
573
|
+
days = days_ago(through)
|
|
574
|
+
"scrubbed #{days} day#{'s' if days != 1} ago"
|
|
575
|
+
end
|
|
576
|
+
|
|
577
|
+
# True while a running `scrub` (RunLock#kind) is currently on this
|
|
578
|
+
# drive - set via RunLock#note as scrub works through its targets.
|
|
579
|
+
def scrubbing?(view) = @running&.kind == 'scrub' && Array(@running.current).include?(view.drive.friendly_name)
|
|
580
|
+
|
|
581
|
+
# Same overdue rule as `status`: something has to have actually synced
|
|
582
|
+
# to the drive first, so a brand-new empty drive is never overdue.
|
|
583
|
+
def scrub_overdue?(view)
|
|
584
|
+
return false unless view.folders.any?(&:last_synced_at)
|
|
585
|
+
|
|
586
|
+
through = manifest.scrubbed_through(view.drive.serial_number)
|
|
587
|
+
through.nil? || Time.parse(through) < (@clock.now - (@scrub_stale_days * 86_400))
|
|
588
|
+
end
|
|
589
|
+
|
|
590
|
+
def days_ago(iso)
|
|
591
|
+
((@clock.now - Time.parse(iso)) / 86_400).floor
|
|
592
|
+
end
|
|
593
|
+
|
|
594
|
+
# "connected", or "last seen 3 days ago" for a drive not mounted now.
|
|
595
|
+
def seen_line(view)
|
|
596
|
+
return nil if view.mounted
|
|
597
|
+
return 'never seen' unless view.drive.last_seen_at
|
|
598
|
+
|
|
599
|
+
days = days_ago(view.drive.last_seen_at)
|
|
600
|
+
days.zero? ? "last seen #{relative_when(view.drive.last_seen_at)}" : "last seen #{days} day#{'s' if days != 1} ago"
|
|
601
|
+
end
|
|
602
|
+
|
|
603
|
+
def scrub_finding_phrase(row)
|
|
604
|
+
return 'unresolved: compare with the NAS copy' if row.status == 'unresolved'
|
|
605
|
+
return 'refetched, awaiting re-check' if row.refetched_at
|
|
606
|
+
|
|
607
|
+
'awaiting refetch'
|
|
608
|
+
end
|
|
609
|
+
end
|
|
610
|
+
end
|
|
611
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module EasySync
|
|
4
|
+
module Jbod
|
|
5
|
+
# Keeps the Mac from idle-sleeping for the life of this process by
|
|
6
|
+
# spawning `caffeinate -i -w <our pid>` as a background child. caffeinate
|
|
7
|
+
# exits on its own the moment the watched process does, so there is
|
|
8
|
+
# nothing to clean up, even after a crash or Ctrl-C. Does nothing when the
|
|
9
|
+
# binary isn't there (Linux, tests).
|
|
10
|
+
class KeepAwake
|
|
11
|
+
CAFFEINATE = '/usr/bin/caffeinate'
|
|
12
|
+
|
|
13
|
+
def initialize(executable: CAFFEINATE, spawner: Process.method(:spawn), detach: Process.method(:detach))
|
|
14
|
+
@executable = executable
|
|
15
|
+
@spawner = spawner
|
|
16
|
+
@detach = detach
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def available? = File.executable?(@executable)
|
|
20
|
+
|
|
21
|
+
# Returns true if a caffeinate child was started.
|
|
22
|
+
def start(pid = Process.pid)
|
|
23
|
+
return false unless available?
|
|
24
|
+
|
|
25
|
+
child = @spawner.call(@executable, '-i', '-w', pid.to_s, in: File::NULL, out: File::NULL, err: File::NULL)
|
|
26
|
+
@detach.call(child)
|
|
27
|
+
true
|
|
28
|
+
rescue SystemCallError
|
|
29
|
+
false
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|