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,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