dwipe 2.0.0__py3-none-any.whl → 2.0.2__py3-none-any.whl

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.
dwipe/DiskWipe.py CHANGED
@@ -5,11 +5,14 @@ DiskWipe class - Main application controller/singleton
5
5
  # pylint: disable=too-many-nested-blocks,too-many-instance-attributes
6
6
  # pylint: disable=too-many-branches,too-many-statements,too-many-locals
7
7
  # pylint: disable=protected-access,too-many-return-statements
8
+ # pylint: disable=too-few-public-methods
8
9
  import os
9
10
  import sys
10
11
  import re
11
12
  import time
12
- import shutil
13
+ import threading
14
+ # import shutil
15
+ import json
13
16
  import curses as cs
14
17
  from types import SimpleNamespace
15
18
  from console_window import (ConsoleWindow, ConsoleWindowOpts, OptionSpinner,
@@ -20,6 +23,8 @@ from .WipeJob import WipeJob
20
23
  from .DeviceInfo import DeviceInfo
21
24
  from .Utils import Utils
22
25
  from .PersistentState import PersistentState
26
+ from .StructuredLogger import StructuredLogger
27
+ from .LsblkMonitor import LsblkMonitor
23
28
 
24
29
  # Screen constants
25
30
  MAIN_ST = 0
@@ -35,8 +40,7 @@ class DiskWipe:
35
40
 
36
41
  def __init__(self, opts=None):
37
42
  DiskWipe.singleton = self
38
- self.opts = opts if opts else SimpleNamespace(debug=0, dry_run=False)
39
- self.DB = bool(self.opts.debug)
43
+ self.opts = opts if opts else SimpleNamespace(debug=0)
40
44
  self.mounts_lines = None
41
45
  self.partitions = {} # a dict of namespaces keyed by name
42
46
  self.wids = None
@@ -67,38 +71,99 @@ class DiskWipe:
67
71
 
68
72
  # Initialize persistent state
69
73
  self.persistent_state = PersistentState()
70
- self.check_preqreqs()
71
-
72
- @staticmethod
73
- def check_preqreqs():
74
- """Check that needed programs are installed."""
75
- ok = True
76
- for prog in 'lsblk'.split():
77
- if shutil.which(prog) is None:
78
- ok = False
79
- print(f'ERROR: cannot find {prog!r} on $PATH')
80
- if not ok:
81
- sys.exit(1)
82
74
 
83
75
  def _start_wipe(self):
84
76
  """Start the wipe job after confirmation"""
85
- if self.confirmation.partition_name and self.confirmation.partition_name in self.partitions:
86
- part = self.partitions[self.confirmation.partition_name]
77
+ if self.confirmation.identity and self.confirmation.identity in self.partitions:
78
+ part = self.partitions[self.confirmation.identity]
87
79
  # Clear any previous verify failure message when starting wipe
88
80
  if hasattr(part, 'verify_failed_msg'):
89
81
  delattr(part, 'verify_failed_msg')
90
- part.job = WipeJob.start_job(f'/dev/{part.name}',
91
- part.size_bytes, opts=self.opts)
92
- self.job_cnt += 1
93
- self.set_state(part, to='0%')
82
+
83
+ # Get the wipe type from user's choice
84
+ wipe_type = self.confirmation.input_buffer.strip()
85
+
86
+ # Check if it's a firmware wipe
87
+ if wipe_type not in ('Zero', 'Rand'):
88
+ # Firmware wipe - check if it's available
89
+ if not part.hw_caps or wipe_type not in part.hw_caps:
90
+ part.mounts = [f'⚠ Firmware wipe {wipe_type} not available']
91
+ self.confirmation.cancel()
92
+ self.win.passthrough_mode = False
93
+ return
94
+
95
+ # Get command args from hw_caps
96
+ command_args = part.hw_caps[wipe_type]
97
+
98
+ # Import firmware task classes
99
+ from .FirmwareWipeTask import NvmeWipeTask, SataWipeTask
100
+
101
+ # Determine task type based on device name
102
+ if part.name.startswith('nvme'):
103
+ task_class = NvmeWipeTask
104
+ else:
105
+ task_class = SataWipeTask
106
+
107
+ # Create firmware task
108
+ task = task_class(
109
+ device_path=f'/dev/{part.name}',
110
+ total_size=part.size_bytes,
111
+ opts=self.opts,
112
+ command_args=command_args,
113
+ wipe_name=wipe_type
114
+ )
115
+
116
+ # Store wipe type for logging
117
+ part.wipe_type = wipe_type
118
+
119
+ # Create WipeJob with single firmware task
120
+ part.job = WipeJob(
121
+ device_path=f'/dev/{part.name}',
122
+ total_size=part.size_bytes,
123
+ opts=self.opts,
124
+ tasks=[task]
125
+ )
126
+ part.job.thread = threading.Thread(target=part.job.run_tasks)
127
+ part.job.thread.start()
128
+
129
+ self.job_cnt += 1
130
+ self.set_state(part, to='0%')
131
+
132
+ # Clear confirmation and return early
133
+ self.confirmation.cancel()
134
+ self.win.passthrough_mode = False
135
+ return
136
+
137
+ # Construct full wipe mode (e.g., 'Zero+V', 'Rand', etc.)
138
+ if self.opts.wipe_mode == '+V':
139
+ full_wipe_mode = wipe_type + '+V'
140
+ else:
141
+ full_wipe_mode = wipe_type
142
+
143
+ # Store wipe type for later logging
144
+ part.wipe_type = wipe_type
145
+
146
+ # Temporarily set the full wipe mode
147
+ old_wipe_mode = self.opts.wipe_mode
148
+ self.opts.wipe_mode = full_wipe_mode
149
+
150
+ try:
151
+ part.job = WipeJob.start_job(f'/dev/{part.name}',
152
+ part.size_bytes, opts=self.opts)
153
+ self.job_cnt += 1
154
+ self.set_state(part, to='0%')
155
+ finally:
156
+ # Restore original wipe_mode
157
+ self.opts.wipe_mode = old_wipe_mode
158
+
94
159
  # Clear confirmation state
95
160
  self.confirmation.cancel()
96
161
  self.win.passthrough_mode = False # Disable passthrough
97
162
 
98
163
  def _start_verify(self):
99
164
  """Start the verify job after confirmation"""
100
- if self.confirmation.partition_name and self.confirmation.partition_name in self.partitions:
101
- part = self.partitions[self.confirmation.partition_name]
165
+ if self.confirmation.identity and self.confirmation.identity in self.partitions:
166
+ part = self.partitions[self.confirmation.identity]
102
167
  # Clear any previous verify failure message when starting verify
103
168
  if hasattr(part, 'verify_failed_msg'):
104
169
  delattr(part, 'verify_failed_msg')
@@ -117,9 +182,9 @@ class DiskWipe:
117
182
  """Set state of partition"""
118
183
  result = self.dev_info.set_one_state(self.partitions, ns, to=to)
119
184
 
120
- # Save lock state changes to persistent state
121
- if result and to in ('Lock', 'Unlk'):
122
- self.persistent_state.set_device_locked(ns, to == 'Lock')
185
+ # Save block state changes to persistent state
186
+ if result and to in ('Blk', 'Unbl'):
187
+ self.persistent_state.set_device_locked(ns, to == 'Blk')
123
188
 
124
189
  return result
125
190
 
@@ -137,6 +202,13 @@ class DiskWipe:
137
202
  if not key:
138
203
  return True
139
204
 
205
+ # Handle search bar input
206
+ if self.stack.curr.num == LOG_ST:
207
+ screen_obj = self.stack.get_curr_obj()
208
+ if screen_obj.search_bar.is_active:
209
+ if screen_obj.search_bar.handle_key(key):
210
+ return None # key handled by search bar
211
+
140
212
  # Handle filter bar input
141
213
  if self.filter_bar.is_active:
142
214
  if self.filter_bar.handle_key(key):
@@ -146,9 +218,9 @@ class DiskWipe:
146
218
  if self.confirmation.active:
147
219
  result = self.confirmation.handle_key(key)
148
220
  if result == 'confirmed':
149
- if self.confirmation.confirm_type == 'wipe':
221
+ if self.confirmation.action_type == 'wipe':
150
222
  self._start_wipe()
151
- elif self.confirmation.confirm_type == 'verify':
223
+ elif self.confirmation.action_type == 'verify':
152
224
  self._start_verify()
153
225
  elif result == 'cancelled':
154
226
  self.confirmation.cancel()
@@ -187,9 +259,8 @@ class DiskWipe:
187
259
  line += f' [P]ass={self.opts.passes}'
188
260
  # Show verification percentage spinner with key
189
261
  line += f' [V]pct={self.opts.verify_pct}%'
190
- line += ' '
191
- if self.opts.dry_run:
192
- line += ' DRY-RUN'
262
+ line += f' [p]ort={self.opts.port_serial}'
263
+ # line += ' !:scan [h]ist [t]heme ?:help [q]uit'
193
264
  line += ' [h]ist [t]heme ?:help [q]uit'
194
265
  return line[1:]
195
266
 
@@ -205,6 +276,8 @@ class DiskWipe:
205
276
  actions['s'] = 'stop'
206
277
  elif self.test_state(part, to='0%'):
207
278
  actions['w'] = 'wipe'
279
+ if part.parent is None:
280
+ actions['DEL'] = 'DEL'
208
281
  # Can verify:
209
282
  # 1. Anything with wipe markers (states 's' or 'W')
210
283
  # 2. Unmarked whole disks (no parent, state '-' or '^') WITHOUT partitions that have filesystems
@@ -229,10 +302,10 @@ class DiskWipe:
229
302
  elif not part.fstype:
230
303
  # Partition without filesystem
231
304
  actions['v'] = 'verify'
232
- if self.test_state(part, to='Lock'):
233
- actions['l'] = 'lock'
234
- if self.test_state(part, to='Unlk'):
235
- actions['l'] = 'unlk'
305
+ if self.test_state(part, to='Blk'):
306
+ actions['b'] = 'block'
307
+ if self.test_state(part, to='Unbl'):
308
+ actions['b'] = 'unblk'
236
309
  return name, actions
237
310
 
238
311
  def _on_filter_change(self, text):
@@ -267,10 +340,28 @@ class DiskWipe:
267
340
  self.prev_filter = ''
268
341
  self.win.passthrough_mode = False
269
342
 
343
+ def get_hw_caps_when_needed(self):
344
+ """ Look for wipeable disks w/o hardware info """
345
+ if not self.dev_info:
346
+ return
347
+ for ns in self.partitions.values():
348
+ if ns.parent:
349
+ continue
350
+ if ns.port.startswith('USB'):
351
+ continue
352
+ if ns.name[:2] not in ('nv', 'sd', 'hd'):
353
+ continue
354
+ if ns.hw_nopes or ns.hw_caps: # already done
355
+ continue
356
+ if self.test_state(ns, to='0%'):
357
+ self.dev_info.get_hw_capabilities(ns)
358
+
359
+
270
360
  def main_loop(self):
271
361
  """Main event loop"""
272
362
 
273
363
  # Create screen instances
364
+ ThemeScreen = Theme.create_picker_screen(DiskWipeScreen)
274
365
  self.screens = {
275
366
  MAIN_ST: MainScreen(self),
276
367
  HELP_ST: HelpScreen(self),
@@ -283,10 +374,27 @@ class DiskWipe:
283
374
  head_line=True,
284
375
  body_rows=200,
285
376
  head_rows=4,
377
+ min_cols_rows=(60,10),
286
378
  # keys=self.spin.keys ^ other_keys,
287
379
  pick_attr=cs.A_REVERSE, # Use reverse video for pick highlighting
288
380
  ctrl_c_terminates=False,
289
381
  )
382
+ lsblk_monitor = LsblkMonitor(check_interval=0.2)
383
+ lsblk_monitor.start()
384
+ print("Starting first lsblk...")
385
+ # Initialize device info and pick range before first draw
386
+ info = DeviceInfo(opts=self.opts, persistent_state=self.persistent_state)
387
+ lsblk_output = None
388
+ while not lsblk_output:
389
+ lsblk_output = lsblk_monitor.get_and_clear()
390
+ self.partitions = info.assemble_partitions(self.partitions, lsblk_output)
391
+ if lsblk_output:
392
+ # print(lsblk_output, '\n\n')
393
+ print('got ... got lsblk result')
394
+ if self.opts.dump_lsblk:
395
+ DeviceInfo.dump(self.partitions, title="after assemble_partitions")
396
+ exit(1)
397
+ time.sleep(0.2)
290
398
 
291
399
  self.win = ConsoleWindow(opts=win_opts)
292
400
  # Initialize screen stack
@@ -295,12 +403,12 @@ class DiskWipe:
295
403
  spin = self.spin = OptionSpinner(stack=self.stack)
296
404
  spin.default_obj = self.opts
297
405
  spin.add_key('dense', 'D - dense/spaced view', vals=[False, True])
298
- spin.add_key('slowdown_stop', 'L - stop if disk slows Nx', vals=[16, 64, 256, 0, 4])
406
+ spin.add_key('port_serial', 'p - disk port info', vals=['Auto', 'On', 'Off'])
407
+ spin.add_key('slowdown_stop', 'W - stop if disk slows Nx', vals=[64, 256, 0, 4, 16])
299
408
  spin.add_key('stall_timeout', 'T - stall timeout (sec)', vals=[60, 120, 300, 600, 0,])
300
409
  spin.add_key('verify_pct', 'V - verification %', vals=[0, 2, 5, 10, 25, 50, 100])
301
410
  spin.add_key('passes', 'P - wipe pass count', vals=[1, 2, 4])
302
- spin.add_key('wipe_mode', 'm - wipe mode', vals=['Zero', 'Zero+V', 'Rand', 'Rand+V'])
303
- spin.add_key('confirmation', 'c - confirmation mode', vals=['YES', 'yes', 'device', 'Y', 'y'])
411
+ spin.add_key('wipe_mode', 'm - wipe mode', vals=['-V', '+V'])
304
412
 
305
413
  spin.add_key('quit', 'q,x - quit program', keys='qx', genre='action')
306
414
  spin.add_key('screen_escape', 'ESC- back one screen',
@@ -310,7 +418,10 @@ class DiskWipe:
310
418
  spin.add_key('wipe', 'w - wipe device', genre='action')
311
419
  spin.add_key('verify', 'v - verify device', genre='action')
312
420
  spin.add_key('stop', 's - stop wipe', genre='action')
313
- spin.add_key('lock', 'l - lock/unlock disk', genre='action')
421
+ spin.add_key('block', 'b - block/unblock disk', genre='action')
422
+ spin.add_key('delete_device', 'DEL - remove disk from lsblk',
423
+ genre='action', keys=(cs.KEY_DC))
424
+ spin.add_key('scan_all_devices', '! - rescan all devices', genre='action')
314
425
  spin.add_key('stop_all', 'S - stop ALL wipes', genre='action')
315
426
  spin.add_key('help', '? - show help screen', genre='action')
316
427
  spin.add_key('history', 'h - show wipe history', genre='action')
@@ -318,60 +429,92 @@ class DiskWipe:
318
429
  spin.add_key('theme_screen', 't - theme picker', genre='action', scope=MAIN_ST)
319
430
  spin.add_key('spin_theme', 't - theme', genre='action', scope=THEME_ST)
320
431
  spin.add_key('header_mode', '_ - header style', vals=['Underline', 'Reverse', 'Off'])
432
+ spin.add_key('expand', 'e - expand history entry', genre='action', scope=LOG_ST)
433
+ spin.add_key('show_keys', 'K - show keys (demo mode)', genre='action')
321
434
  self.opts.theme = ''
322
435
  self.persistent_state.restore_updated_opts(self.opts)
323
436
  Theme.set(self.opts.theme)
324
437
  self.win.set_handled_keys(self.spin.keys)
325
438
 
439
+ # Start background lsblk monitor
326
440
 
327
- # self.opts.name = "[hit 'n' to enter name]"
328
-
329
- # Initialize device info and pick range before first draw
330
- info = DeviceInfo(opts=self.opts, persistent_state=self.persistent_state)
331
- self.partitions = info.assemble_partitions(self.partitions)
441
+ self.get_hw_caps_when_needed()
332
442
  self.dev_info = info
333
443
  pick_range = info.get_pick_range()
334
444
  self.win.set_pick_range(pick_range[0], pick_range[1])
335
445
 
336
- check_devices_mono = time.monotonic()
337
- while True:
338
- # Draw current screen
339
- current_screen = self.screens[self.stack.curr.num]
340
- current_screen.draw_screen()
341
- self.win.render()
342
-
343
- seconds = 3.0
344
- _ = self.do_key(self.win.prompt(seconds=seconds))
345
-
346
- # Handle actions using perform_actions
347
- self.stack.perform_actions(spin)
348
-
349
- if time.monotonic() - check_devices_mono > (seconds * 0.95):
350
- info = DeviceInfo(opts=self.opts, persistent_state=self.persistent_state)
351
- self.partitions = info.assemble_partitions(self.partitions)
352
- self.dev_info = info
353
- # Update pick range to highlight NAME through SIZE fields
354
- pick_range = info.get_pick_range()
355
- self.win.set_pick_range(pick_range[0], pick_range[1])
356
- check_devices_mono = time.monotonic()
357
446
 
358
- # Save any persistent state changes
359
- self.persistent_state.save_updated_opts(self.opts)
360
- self.persistent_state.sync()
447
+ check_devices_mono = time.monotonic()
361
448
 
362
- self.win.clear()
449
+ try:
450
+ while True:
451
+ # Draw current screen
452
+ current_screen = self.screens[self.stack.curr.num]
453
+ current_screen.draw_screen()
454
+ self.win.render()
455
+
456
+ # Main thread timeout for responsive UI (background monitor checks every 0.2s)
457
+ _ = self.do_key(self.win.prompt(seconds=0.25))
458
+
459
+ # Handle actions using perform_actions
460
+ self.stack.perform_actions(spin)
461
+
462
+ # Check for new lsblk data from background monitor
463
+ lsblk_output = lsblk_monitor.get_and_clear()
464
+ time_since_refresh = time.monotonic() - check_devices_mono
465
+
466
+ if lsblk_output or time_since_refresh > 3.0:
467
+ # Refresh if: device changes detected OR periodic refresh (3s default)
468
+ info = DeviceInfo(opts=self.opts, persistent_state=self.persistent_state)
469
+ self.partitions = info.assemble_partitions(self.partitions, lsblk_output=lsblk_output)
470
+ self.get_hw_caps_when_needed()
471
+ self.dev_info = info
472
+ # Update pick range to highlight NAME through SIZE fields
473
+ pick_range = info.get_pick_range()
474
+ self.win.set_pick_range(pick_range[0], pick_range[1])
475
+ check_devices_mono = time.monotonic()
476
+
477
+ # Save any persistent state changes
478
+ self.persistent_state.save_updated_opts(self.opts)
479
+ self.persistent_state.sync()
480
+
481
+ self.win.clear()
482
+ finally:
483
+ # Clean up monitor thread on exit
484
+ lsblk_monitor.stop()
363
485
 
364
486
  class DiskWipeScreen(Screen):
365
487
  """ TBD """
366
488
  app: DiskWipe
489
+ refresh_seconds = 3.0 # Default refresh rate for screens
367
490
 
368
491
  def screen_escape_ACTION(self):
369
492
  """ return to main screen """
370
493
  self.app.stack.pop()
371
494
 
495
+ def show_keys_ACTION(self):
496
+ """ Show last key for demo"""
497
+ self.app.win.set_demo_mode(enabled=None) # toggle it
498
+
372
499
  class MainScreen(DiskWipeScreen):
373
500
  """Main device list screen"""
374
501
 
502
+ def __init__(self, app):
503
+ super().__init__(app)
504
+ self.app = app
505
+ self.persist_port_serial = set()
506
+
507
+
508
+ def _port_serial_line(self, partition):
509
+ wids = self.app.wids
510
+ wid = wids.state if wids else 5
511
+ sep, key_str = ' ', ''
512
+ port, serial = partition.port, partition.serial
513
+ if partition.hw_caps or partition.hw_nopes:
514
+ lead = 'CAPS' if partition.hw_caps else 'ERRS'
515
+ infos = partition.hw_caps if partition.hw_caps else partition.hw_nopes
516
+ key_str = f' Fw{lead}: ' + ','.join(list(infos.keys()))
517
+ return f'{"":>{wid}}{sep}│ └────── {port:<12} {serial}{key_str}'
375
518
 
376
519
  def draw_screen(self):
377
520
  """Draw the main device list"""
@@ -381,6 +524,12 @@ class MainScreen(DiskWipeScreen):
381
524
  return not app.filter or app.filter.search(name)
382
525
 
383
526
  app.win.set_pick_mode(True)
527
+ if app.opts.port_serial != 'Auto':
528
+ self.persist_port_serial = set() # name of disks
529
+ else: # if the disk goes away, clear persistence
530
+ for name in list(self.persist_port_serial):
531
+ if name not in app.partitions:
532
+ self.persist_port_serial.discard(name)
384
533
 
385
534
  # First pass: process jobs and collect visible partitions
386
535
  visible_partitions = []
@@ -467,6 +616,9 @@ class MainScreen(DiskWipeScreen):
467
616
  else:
468
617
  verify_detail = verify_result
469
618
 
619
+ # Structured logging
620
+ Utils.log_wipe_structured(app.partitions, partition, partition.job)
621
+ # Legacy text log (keep for compatibility)
470
622
  Utils.log_wipe(partition.name, partition.size_bytes, 'Vrfy', result, elapsed,
471
623
  uuid=partition.uuid, verify_result=verify_detail)
472
624
  app.job_cnt -= 1
@@ -476,6 +628,7 @@ class MainScreen(DiskWipeScreen):
476
628
  partition.state = partition.dflt
477
629
  partition.job = None
478
630
  partition.marker_checked = False # Reset to "dont-know" - will re-read on next scan
631
+ partition.marker = '' # Clear stale marker string to avoid showing old data during re-read
479
632
  else:
480
633
  # Wipe job completed (with or without auto-verify)
481
634
  # Check if stopped during verify phase (after successful write)
@@ -508,12 +661,15 @@ class MainScreen(DiskWipeScreen):
508
661
  # Log the wipe operation
509
662
  elapsed = time.monotonic() - partition.job.start_mono
510
663
  result = 'stopped' if partition.job.do_abort else 'completed'
511
- # Extract base mode (remove '+V' suffix if present)
512
- mode = app.opts.wipe_mode.replace('+V', '')
664
+ # Get the wipe type that was used (stored when wipe was started)
665
+ mode = getattr(partition, 'wipe_type', 'Unknown')
513
666
  # Calculate percentage if stopped
514
667
  pct = None
515
668
  if partition.job.do_abort and partition.job.total_size > 0:
516
669
  pct = int((partition.job.total_written / partition.job.total_size) * 100)
670
+ # Structured logging
671
+ Utils.log_wipe_structured(app.partitions, partition, partition.job, mode=mode)
672
+ # Legacy text log (keep for compatibility)
517
673
  # Only pass label/fstype for stopped wipes (not completed)
518
674
  if result == 'stopped':
519
675
  Utils.log_wipe(partition.name, partition.size_bytes, mode, result, elapsed,
@@ -546,37 +702,51 @@ class MainScreen(DiskWipeScreen):
546
702
  else:
547
703
  verify_detail = verify_result
548
704
 
705
+ # Note: Structured logging for verify was already logged above as part of the wipe
706
+ # This is just logging the separate verify phase stats to the legacy log
549
707
  Utils.log_wipe(partition.name, partition.size_bytes, 'Vrfy', result, verify_elapsed,
550
708
  uuid=partition.uuid, verify_result=verify_detail)
551
709
 
552
710
  if partition.job.exception:
553
- app.win.stop_curses()
554
- print('\n\n\n========== ALERT =========\n')
555
- print(f' FAILED: wipe {repr(partition.name)}')
556
- print(partition.job.exception)
557
- input('\n\n===== Press ENTER to continue ====> ')
558
- app.win._start_curses()
711
+ app.win.alert(
712
+ message=f'FAILED: wipe {repr(partition.name)}\n{partition.job.exception}',
713
+ title='ALERT'
714
+ )
559
715
 
560
716
  partition.job = None
561
717
  partition.marker_checked = False # Reset to "dont-know" - will re-read on next scan
718
+ partition.marker = '' # Clear stale marker string to avoid showing old data during re-read
562
719
  if partition.job:
563
720
  elapsed, pct, rate, until = partition.job.get_status()
564
721
 
722
+ # Get task display name (Zero, Rand, Crypto, Verify, etc.)
723
+ task_name = ""
724
+ if partition.job.current_task:
725
+ task_name = partition.job.current_task.get_display_name()
726
+
565
727
  # FLUSH goes in mounts column, not state
566
728
  if pct.startswith('FLUSH'):
567
729
  partition.state = partition.dflt # Keep default state (s, W, etc)
568
730
  if rate and until:
569
- partition.mounts = [f'{pct} {elapsed} -{until} {rate}']
731
+ partition.mounts = [f'{task_name} {pct} {elapsed} -{until} {rate}']
570
732
  else:
571
- partition.mounts = [f'{pct} {elapsed}']
733
+ partition.mounts = [f'{task_name} {pct} {elapsed}']
572
734
  else:
573
735
  partition.state = pct
574
- slowdown = partition.job.max_slowdown_ratio # temp?
575
- stall = partition.job.max_stall_secs # temp
576
- partition.mounts = [f'{elapsed} -{until} {rate} ÷{slowdown} 𝚫{Utils.ago_str(stall)}']
736
+ # Build progress line with task name
737
+ progress_parts = [task_name, elapsed, f'-{until}', rate]
738
+
739
+ # Only show slowdown/stall if job tracks these metrics
740
+ # (WriteTask does, VerifyTask and FirmwareWipeTask don't)
741
+ if hasattr(partition.job, 'max_slowdown_ratio') and hasattr(partition.job, 'max_stall_secs'):
742
+ slowdown = partition.job.max_slowdown_ratio
743
+ stall = partition.job.max_stall_secs
744
+ progress_parts.extend([f'÷{slowdown}', f'𝚫{Utils.ago_str(stall)}'])
745
+
746
+ partition.mounts = [' '.join(progress_parts)]
577
747
 
578
748
  if partition.parent and partition.parent in app.partitions and (
579
- app.partitions[partition.parent].state == 'Lock'):
749
+ app.partitions[partition.parent].state == 'Blk'):
580
750
  continue
581
751
 
582
752
  if wanted(name) or partition.job:
@@ -612,34 +782,52 @@ class MainScreen(DiskWipeScreen):
612
782
  ctx = Context(genre='disk' if partition.parent is None else 'partition',
613
783
  partition=partition)
614
784
  app.win.add_body(partition.line, attr=attr, context=ctx)
785
+ if partition.parent is None and app.opts.port_serial != 'Off':
786
+ doit = bool(app.opts.port_serial == 'On')
787
+ if not doit:
788
+ doit = bool(partition.name in self.persist_port_serial)
789
+ if not doit and app.test_state(partition, to='0%'):
790
+ doit = True
791
+ self.persist_port_serial.add(partition.name)
792
+ if doit:
793
+ line = self._port_serial_line(partition)
794
+ app.win.add_body(line, attr=attr, context=Context(genre='DECOR'))
615
795
 
616
796
  # Show inline confirmation prompt if this is the partition being confirmed
617
- if app.confirmation.active and app.confirmation.partition_name == partition.name:
797
+ if app.confirmation.active and app.confirmation.identity == partition.name:
618
798
  # Build confirmation message
619
- if app.confirmation.confirm_type == 'wipe':
620
- msg = f'⚠️ WIPE {partition.name} ({Utils.human(partition.size_bytes)})'
799
+ if app.confirmation.action_type == 'wipe':
800
+ msg = f'⚠️ WIPE {partition.name}'
621
801
  else: # verify
622
- msg = f'⚠️ VERIFY {partition.name} ({Utils.human(partition.size_bytes)}) - writes marker'
623
-
624
- # Add mode-specific prompt
625
- if app.confirmation.mode == 'Y':
626
- msg += " - Press 'Y' or ESC"
627
- elif app.confirmation.mode == 'y':
628
- msg += " - Press 'y' or ESC"
629
- elif app.confirmation.mode == 'YES':
630
- msg += f" - Type 'YES': {app.confirmation.input_buffer}_"
631
- elif app.confirmation.mode == 'yes':
632
- msg += f" - Type 'yes': {app.confirmation.input_buffer}_"
633
- elif app.confirmation.mode == 'device':
634
- msg += f" - Type '{partition.name}': {app.confirmation.input_buffer}_"
802
+ msg = f'⚠️ VERIFY {partition.name} [writes marker]'
803
+
804
+ # Add mode-specific prompt (base message without input)
805
+ if app.confirmation.mode == 'yes':
806
+ msg += " - Type 'yes': "
807
+ elif app.confirmation.mode == 'identity':
808
+ msg += f" - Type '{partition.name}': "
809
+ elif app.confirmation.mode == 'choices':
810
+ choices_str = ', '.join(app.confirmation.choices)
811
+ msg += f" - Choose ({choices_str}): "
635
812
 
636
813
  # Position message at fixed column (reduced from 28 to 20)
637
814
  msg = ' ' * 20 + msg
638
815
 
639
- # Add confirmation message as DECOR (non-pickable)
816
+ # Add confirmation message base as DECOR (non-pickable)
640
817
  app.win.add_body(msg, attr=cs.color_pair(Theme.DANGER) | cs.A_BOLD,
641
818
  context=Context(genre='DECOR'))
642
819
 
820
+ # Add input or hint on same line
821
+ if app.confirmation.input_buffer:
822
+ # Show current input with cursor
823
+ app.win.add_body(app.confirmation.input_buffer + '_',
824
+ attr=cs.color_pair(Theme.DANGER) | cs.A_BOLD,
825
+ resume=True)
826
+ else:
827
+ # Show hint in dimmed italic
828
+ hint = app.confirmation.get_hint()
829
+ app.win.add_body(hint, attr=cs.A_DIM | cs.A_ITALIC, resume=True)
830
+
643
831
  app.win.add_fancy_header(app.get_keys_line(), mode=app.opts.header_mode)
644
832
 
645
833
  app.win.add_header(app.dev_info.head_str, attr=cs.A_DIM)
@@ -708,7 +896,12 @@ class MainScreen(DiskWipeScreen):
708
896
  part = ctx.partition
709
897
  if app.test_state(part, to='0%'):
710
898
  self.clear_hotswap_marker(part)
711
- app.confirmation.start('wipe', part.name, app.opts.confirmation)
899
+ # Build choices: Zero, Rand, and any firmware wipe types
900
+ choices = ['Zero', 'Rand']
901
+ if part.hw_caps:
902
+ choices.extend(list(part.hw_caps.keys()))
903
+ app.confirmation.start(action_type='wipe',
904
+ identity=part.name, mode='choices', choices=choices)
712
905
  app.win.passthrough_mode = True
713
906
 
714
907
  def verify_ACTION(self):
@@ -726,7 +919,8 @@ class MainScreen(DiskWipeScreen):
726
919
  is_unmarked = part.state == '-' and (not part.parent or not part.fstype)
727
920
  if is_unmarked:
728
921
  # Require confirmation for unmarked partitions
729
- app.confirmation.start('verify', part.name, app.opts.confirmation)
922
+ app.confirmation.start(action_type='verify',
923
+ identity=part.name, mode="yes")
730
924
  app.win.passthrough_mode = True
731
925
  else:
732
926
  # Marked partition - proceed directly
@@ -737,6 +931,39 @@ class MainScreen(DiskWipeScreen):
737
931
  part.size_bytes, opts=app.opts)
738
932
  app.job_cnt += 1
739
933
 
934
+ def scan_all_devices_ACTION(self):
935
+ """ Trigger a re-scan of all devices to make the appear
936
+ quicker in the list"""
937
+ base_path = '/sys/class/scsi_host'
938
+ if not os.path.exists(base_path):
939
+ return
940
+ for host in os.listdir(base_path):
941
+ scan_file = os.path.join(base_path, host, 'scan')
942
+ if os.path.exists(scan_file):
943
+ try:
944
+ with open(scan_file, 'w', encoding='utf-8') as f:
945
+ f.write("- - -")
946
+ except Exception:
947
+ pass
948
+
949
+ def delete_device_ACTION(self):
950
+ """ DEL key -- Cause the OS to drop a sata device so it
951
+ can be replaced sooner """
952
+ app = self.app
953
+ ctx = app.win.get_picked_context()
954
+ if ctx and hasattr(ctx, 'partition'):
955
+ part = ctx.partition
956
+ if not part or part.parent or not app.test_state(part, to='0%'):
957
+ return
958
+ path = f"/sys/block/{part.name}/device/delete"
959
+ if os.path.exists(path):
960
+ try:
961
+ with open(path, 'w', encoding='utf-8') as f:
962
+ f.write("1")
963
+ return True
964
+ except Exception:
965
+ pass
966
+
740
967
  def stop_ACTION(self):
741
968
  """Handle 's' key"""
742
969
  app = self.app
@@ -748,6 +975,7 @@ class MainScreen(DiskWipeScreen):
748
975
  if part.job and not part.job.done:
749
976
  part.job.do_abort = True
750
977
 
978
+
751
979
  def stop_all_ACTION(self):
752
980
  """Handle 'S' key"""
753
981
  app = self.app
@@ -756,14 +984,14 @@ class MainScreen(DiskWipeScreen):
756
984
  if part.job and not part.job.done:
757
985
  part.job.do_abort = True
758
986
 
759
- def lock_ACTION(self):
760
- """Handle 'l' key"""
987
+ def block_ACTION(self):
988
+ """Handle 'b' key"""
761
989
  app = self.app
762
990
  ctx = app.win.get_picked_context()
763
991
  if ctx and hasattr(ctx, 'partition'):
764
992
  part = ctx.partition
765
993
  self.clear_hotswap_marker(part)
766
- app.set_state(part, 'Unlk' if part.state == 'Lock' else 'Lock')
994
+ app.set_state(part, 'Unbl' if part.state == 'Blk' else 'Blk')
767
995
 
768
996
  def help_ACTION(self):
769
997
  """Handle '?' key - push help screen"""
@@ -800,81 +1028,178 @@ class HelpScreen(DiskWipeScreen):
800
1028
 
801
1029
 
802
1030
  class HistoryScreen(DiskWipeScreen):
803
- """History/log screen showing wipe history"""
1031
+ """History/log screen showing structured log entries with expand/collapse functionality"""
1032
+
1033
+ refresh_seconds = 60.0 # Slower refresh for history screen to allow copy/paste
1034
+
1035
+ def __init__(self, app):
1036
+ super().__init__(app)
1037
+ self.expands = {} # Maps timestamp -> True (expanded) or False (collapsed)
1038
+ self.entries = [] # Cached log entries (all entries before filtering)
1039
+ self.filtered_entries = [] # Entries after search filtering
1040
+ self.window_of_logs = None # Window of log entries (OrderedDict)
1041
+ self.window_state = None # Window state for incremental reads
1042
+ self.search_matches = set() # Set of timestamps with deep-only matches in JSON
1043
+ self.prev_filter = ''
1044
+
1045
+ # Setup search bar
1046
+ self.search_bar = IncrementalSearchBar(
1047
+ on_change=self._on_search_change,
1048
+ on_accept=self._on_search_accept,
1049
+ on_cancel=self._on_search_cancel
1050
+ )
1051
+
1052
+ def _on_search_change(self, text):
1053
+ """Called when search text changes - filter entries incrementally."""
1054
+ self._filter_entries(text)
1055
+
1056
+ def _on_search_accept(self, text):
1057
+ """Called when ENTER pressed in search - keep filter active, exit input mode."""
1058
+ self.app.win.passthrough_mode = False
1059
+ self.prev_filter = text
1060
+
1061
+ def _on_search_cancel(self, original_text):
1062
+ """Called when ESC pressed in search - restore and exit search mode."""
1063
+ self._filter_entries(original_text)
1064
+ self.app.win.passthrough_mode = False
1065
+
1066
+ def _filter_entries(self, search_text):
1067
+ """Filter entries based on search text (shallow or deep)."""
1068
+ if not search_text:
1069
+ self.filtered_entries = self.entries
1070
+ self.search_matches = set()
1071
+ return
1072
+
1073
+ # Deep search mode if starts with /
1074
+ deep_search = search_text.startswith('/')
1075
+ pattern = search_text[1:] if deep_search else search_text
1076
+
1077
+ if not pattern:
1078
+ self.filtered_entries = self.entries
1079
+ self.search_matches = set()
1080
+ return
1081
+
1082
+ # Use StructuredLogger's filter method
1083
+ # logger = Utils.get_logger()
1084
+ self.filtered_entries, self.search_matches = StructuredLogger.filter_entries(
1085
+ self.entries, pattern, deep=deep_search
1086
+ )
804
1087
 
805
1088
  def draw_screen(self):
806
- """Draw the history screen"""
1089
+ """Draw the history screen with structured log entries"""
807
1090
  app = self.app
808
- # spinner = self.get_spinner()
1091
+ win = app.win
1092
+ win.set_pick_mode(True)
809
1093
 
810
- app.win.set_pick_mode(False)
1094
+ # Get window of log entries (chronological order - eldest to youngest)
1095
+ logger = Utils.get_logger()
1096
+ if self.window_of_logs is None:
1097
+ self.window_of_logs, self.window_state = logger.get_window_of_entries(window_size=1000)
1098
+ else:
1099
+ # Refresh window with any new entries
1100
+ self.window_of_logs, self.window_state = logger.refresh_window(
1101
+ self.window_of_logs, self.window_state, window_size=1000
1102
+ )
1103
+
1104
+ # Convert to list in reverse order (newest first for display)
1105
+ self.entries = list(reversed(list(self.window_of_logs.values())))
1106
+
1107
+ # Clean up self.expands: remove any timestamps that are no longer in entries
1108
+ valid_timestamps = {entry.timestamp for entry in self.entries}
1109
+ self.expands = {ts: state for ts, state in self.expands.items() if ts in valid_timestamps}
1110
+
1111
+ # Apply search filter if active
1112
+ if not self.search_bar.text:
1113
+ self.filtered_entries = self.entries
1114
+ self.search_matches = set()
1115
+
1116
+ # Count by level in filtered results
1117
+ level_counts = {}
1118
+ for e in self.filtered_entries:
1119
+ level_counts[e.level] = level_counts.get(e.level, 0) + 1
1120
+
1121
+ # Build search display string
1122
+ search_display = self.search_bar.get_display_string(prefix='', suffix='')
1123
+
1124
+ # Build level summary for header
1125
+ # level_summary = ' '.join(f'{lvl}:{cnt}' for lvl, cnt in sorted(level_counts.items()))
1126
+
1127
+ # Header
1128
+ # header_line = f'ESC:back [e]xpand [/]search {len(self.filtered_entries)}/{len(self.entries)} ({level_summary}) '
1129
+ header_line = f'ESC:back [e]xpand [/]search {len(self.filtered_entries)}/{len(self.entries)} '
1130
+ if search_display:
1131
+ header_line += f'/ {search_display}'
1132
+ else:
1133
+ header_line += '/'
1134
+ win.add_header(header_line)
1135
+ win.add_header(f'Log: {logger.log_file}')
811
1136
 
812
- # Add header
813
- app.win.add_header('WIPE HISTORY (newest first)', attr=cs.A_BOLD)
814
- app.win.add_header(' Press ESC to return', resume=True)
1137
+ # Build display
1138
+ for entry in self.filtered_entries:
1139
+ timestamp = entry.timestamp
815
1140
 
816
- # Read and display log file in reverse order
817
- log_path = Utils.get_log_path()
818
- if log_path.exists():
819
- try:
820
- with open(log_path, 'r', encoding='utf-8') as f:
821
- lines = f.readlines()
822
-
823
- # Show in reverse order (newest first)
824
- for line in reversed(lines):
825
- app.win.put_body(line.rstrip())
826
- except Exception as e:
827
- app.win.put_body(f'Error reading log: {e}')
828
- else:
829
- app.win.put_body('No wipe history found.')
830
- app.win.put_body('')
831
- app.win.put_body(f'Log file will be created at: {log_path}')
1141
+ # Get display summary from entry
1142
+ summary = entry.display_summary
832
1143
 
1144
+ # Format timestamp (just date and time)
1145
+ timestamp_display = timestamp[:19] # YYYY-MM-DD HH:MM:SS
833
1146
 
834
- class ThemeScreen(DiskWipeScreen):
835
- """Theme preview screen showing all available themes with color examples"""
836
- prev_theme = ""
1147
+ level = entry.level
837
1148
 
838
- def draw_screen(self):
839
- """Draw the theme screen with color examples for all themes"""
840
- app = self.app
1149
+ # Add deep match indicator if this entry matched only in JSON
1150
+ deep_indicator = " *" if timestamp in self.search_matches else ""
841
1151
 
842
- app.win.set_pick_mode(False)
1152
+ # Choose color based on log level
1153
+ if level == 'ERR':
1154
+ level_attr = cs.color_pair(Theme.ERROR) | cs.A_BOLD
1155
+ elif level in ('WIPE_STOPPED', 'VERIFY_STOPPED'):
1156
+ level_attr = cs.color_pair(Theme.WARNING) | cs.A_BOLD
1157
+ elif level in ('WIPE_COMPLETE', 'VERIFY_COMPLETE'):
1158
+ level_attr = cs.color_pair(Theme.SUCCESS) | cs.A_BOLD
1159
+ else:
1160
+ level_attr = cs.A_BOLD
1161
+
1162
+ line = f"{timestamp_display} {summary}{deep_indicator}"
1163
+ win.add_body(line, attr=level_attr, context=Context("header", timestamp=timestamp))
1164
+
1165
+ # Handle expansion - show the structured data
1166
+ if self.expands.get(timestamp, False):
1167
+ # Show the full entry data as formatted JSON
1168
+ try:
1169
+ data_dict = entry.to_dict()
1170
+ # Format just the 'data' field if it exists, otherwise show all
1171
+ if 'data' in data_dict and data_dict['data']:
1172
+ formatted = json.dumps(data_dict['data'], indent=2)
1173
+ else:
1174
+ formatted = json.dumps(data_dict, indent=2)
1175
+
1176
+ lines = formatted.split('\n')
1177
+ for line in lines:
1178
+ win.add_body(f" {line}", context=Context("body", timestamp=timestamp))
1179
+
1180
+ except Exception as e:
1181
+ win.add_body(f" (error formatting: {e})", attr=cs.A_DIM)
1182
+
1183
+ # Empty line between entries
1184
+ win.add_body("", context=Context("DECOR"))
1185
+
1186
+ def expand_ACTION(self):
1187
+ """'e' key - Expand/collapse current entry"""
1188
+ app = self.app
1189
+ win = app.win
1190
+ ctx = win.get_picked_context()
1191
+
1192
+ if ctx and hasattr(ctx, 'timestamp'):
1193
+ timestamp = ctx.timestamp
1194
+ # Toggle between collapsed and expanded
1195
+ current = self.expands.get(timestamp, False)
1196
+ if current:
1197
+ del self.expands[timestamp] # Collapse
1198
+ else:
1199
+ self.expands[timestamp] = True # Expand
843
1200
 
844
- # Add header showing current theme
845
-
846
- app.win.add_header(f'COLOR THEME: {app.opts.theme:^18}', attr=cs.A_BOLD)
847
- app.win.add_header(' Press [t] to cycle themes, ESC to return', resume=True)
848
-
849
- # Color purpose labels
850
- color_labels = [
851
- (Theme.DANGER, 'DANGER', 'Destructive operations (wipe prompts)'),
852
- (Theme.SUCCESS, 'SUCCESS', 'Completed operations'),
853
- (Theme.OLD_SUCCESS, 'OLD_SUCCESS', 'Older Completed operations'),
854
- (Theme.WARNING, 'WARNING', 'Caution/stopped states'),
855
- (Theme.INFO, 'INFO', 'Informational states'),
856
- (Theme.EMPHASIS, 'EMPHASIS', 'Emphasized text'),
857
- (Theme.ERROR, 'ERROR', 'Errors'),
858
- (Theme.PROGRESS, 'PROGRESS', 'Progress indicators'),
859
- (Theme.HOTSWAP, 'HOTSWAP', 'Newly inserted devices'),
860
- ]
861
-
862
- # Display color examples for current theme
863
- theme_info = Theme.THEMES[app.opts.theme]
864
- _ = theme_info.get('name', app.opts.theme)
865
-
866
- # Show color examples for this theme
867
- for color_id, label, description in color_labels:
868
- # Create line with colored block and description
869
- line = f'{label:12} ████████ {description}'
870
- attr = cs.color_pair(color_id)
871
- app.win.add_body(line, attr=attr)
872
-
873
- def spin_theme_ACTION(self):
874
- """ TBD """
875
- vals = Theme.list_all()
876
- value = Theme.get_current()
877
- idx = vals.index(value) if value in vals else -1
878
- value = vals[(idx+1) % len(vals)] # choose next
879
- Theme.set(value)
880
- self.app.opts.theme = value
1201
+ def filter_ACTION(self):
1202
+ """'/' key - Start incremental search"""
1203
+ app = self.app
1204
+ self.search_bar.start(self.prev_filter)
1205
+ app.win.passthrough_mode = True