dwipe 2.0.1__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,10 +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 += f' [p]ort'
191
- line += ' '
192
- if self.opts.dry_run:
193
- line += ' DRY-RUN'
262
+ line += f' [p]ort={self.opts.port_serial}'
263
+ # line += ' !:scan [h]ist [t]heme ?:help [q]uit'
194
264
  line += ' [h]ist [t]heme ?:help [q]uit'
195
265
  return line[1:]
196
266
 
@@ -206,6 +276,8 @@ class DiskWipe:
206
276
  actions['s'] = 'stop'
207
277
  elif self.test_state(part, to='0%'):
208
278
  actions['w'] = 'wipe'
279
+ if part.parent is None:
280
+ actions['DEL'] = 'DEL'
209
281
  # Can verify:
210
282
  # 1. Anything with wipe markers (states 's' or 'W')
211
283
  # 2. Unmarked whole disks (no parent, state '-' or '^') WITHOUT partitions that have filesystems
@@ -230,10 +302,10 @@ class DiskWipe:
230
302
  elif not part.fstype:
231
303
  # Partition without filesystem
232
304
  actions['v'] = 'verify'
233
- if self.test_state(part, to='Lock'):
234
- actions['l'] = 'lock'
235
- if self.test_state(part, to='Unlk'):
236
- 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'
237
309
  return name, actions
238
310
 
239
311
  def _on_filter_change(self, text):
@@ -268,10 +340,28 @@ class DiskWipe:
268
340
  self.prev_filter = ''
269
341
  self.win.passthrough_mode = False
270
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
+
271
360
  def main_loop(self):
272
361
  """Main event loop"""
273
362
 
274
363
  # Create screen instances
364
+ ThemeScreen = Theme.create_picker_screen(DiskWipeScreen)
275
365
  self.screens = {
276
366
  MAIN_ST: MainScreen(self),
277
367
  HELP_ST: HelpScreen(self),
@@ -284,10 +374,27 @@ class DiskWipe:
284
374
  head_line=True,
285
375
  body_rows=200,
286
376
  head_rows=4,
377
+ min_cols_rows=(60,10),
287
378
  # keys=self.spin.keys ^ other_keys,
288
379
  pick_attr=cs.A_REVERSE, # Use reverse video for pick highlighting
289
380
  ctrl_c_terminates=False,
290
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)
291
398
 
292
399
  self.win = ConsoleWindow(opts=win_opts)
293
400
  # Initialize screen stack
@@ -296,13 +403,12 @@ class DiskWipe:
296
403
  spin = self.spin = OptionSpinner(stack=self.stack)
297
404
  spin.default_obj = self.opts
298
405
  spin.add_key('dense', 'D - dense/spaced view', vals=[False, True])
299
- spin.add_key('port_serial', 'p - disk port info', vals=[False, True])
300
- 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])
301
408
  spin.add_key('stall_timeout', 'T - stall timeout (sec)', vals=[60, 120, 300, 600, 0,])
302
409
  spin.add_key('verify_pct', 'V - verification %', vals=[0, 2, 5, 10, 25, 50, 100])
303
410
  spin.add_key('passes', 'P - wipe pass count', vals=[1, 2, 4])
304
- spin.add_key('wipe_mode', 'm - wipe mode', vals=['Zero', 'Zero+V', 'Rand', 'Rand+V'])
305
- 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'])
306
412
 
307
413
  spin.add_key('quit', 'q,x - quit program', keys='qx', genre='action')
308
414
  spin.add_key('screen_escape', 'ESC- back one screen',
@@ -312,7 +418,10 @@ class DiskWipe:
312
418
  spin.add_key('wipe', 'w - wipe device', genre='action')
313
419
  spin.add_key('verify', 'v - verify device', genre='action')
314
420
  spin.add_key('stop', 's - stop wipe', genre='action')
315
- 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')
316
425
  spin.add_key('stop_all', 'S - stop ALL wipes', genre='action')
317
426
  spin.add_key('help', '? - show help screen', genre='action')
318
427
  spin.add_key('history', 'h - show wipe history', genre='action')
@@ -320,65 +429,92 @@ class DiskWipe:
320
429
  spin.add_key('theme_screen', 't - theme picker', genre='action', scope=MAIN_ST)
321
430
  spin.add_key('spin_theme', 't - theme', genre='action', scope=THEME_ST)
322
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')
323
434
  self.opts.theme = ''
324
435
  self.persistent_state.restore_updated_opts(self.opts)
325
436
  Theme.set(self.opts.theme)
326
437
  self.win.set_handled_keys(self.spin.keys)
327
438
 
439
+ # Start background lsblk monitor
328
440
 
329
- # self.opts.name = "[hit 'n' to enter name]"
330
-
331
- # Initialize device info and pick range before first draw
332
- info = DeviceInfo(opts=self.opts, persistent_state=self.persistent_state)
333
- self.partitions = info.assemble_partitions(self.partitions)
441
+ self.get_hw_caps_when_needed()
334
442
  self.dev_info = info
335
443
  pick_range = info.get_pick_range()
336
444
  self.win.set_pick_range(pick_range[0], pick_range[1])
337
445
 
338
- check_devices_mono = time.monotonic()
339
- while True:
340
- # Draw current screen
341
- current_screen = self.screens[self.stack.curr.num]
342
- current_screen.draw_screen()
343
- self.win.render()
344
-
345
- seconds = 3.0
346
- _ = self.do_key(self.win.prompt(seconds=seconds))
347
-
348
- # Handle actions using perform_actions
349
- self.stack.perform_actions(spin)
350
-
351
- if time.monotonic() - check_devices_mono > (seconds * 0.95):
352
- info = DeviceInfo(opts=self.opts, persistent_state=self.persistent_state)
353
- self.partitions = info.assemble_partitions(self.partitions)
354
- self.dev_info = info
355
- # Update pick range to highlight NAME through SIZE fields
356
- pick_range = info.get_pick_range()
357
- self.win.set_pick_range(pick_range[0], pick_range[1])
358
- check_devices_mono = time.monotonic()
359
446
 
360
- # Save any persistent state changes
361
- self.persistent_state.save_updated_opts(self.opts)
362
- self.persistent_state.sync()
447
+ check_devices_mono = time.monotonic()
363
448
 
364
- 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()
365
485
 
366
486
  class DiskWipeScreen(Screen):
367
487
  """ TBD """
368
488
  app: DiskWipe
489
+ refresh_seconds = 3.0 # Default refresh rate for screens
369
490
 
370
491
  def screen_escape_ACTION(self):
371
492
  """ return to main screen """
372
493
  self.app.stack.pop()
373
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
+
374
499
  class MainScreen(DiskWipeScreen):
375
500
  """Main device list screen"""
376
501
 
377
- def _port_serial_line(self, port, serial):
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):
378
509
  wids = self.app.wids
379
510
  wid = wids.state if wids else 5
380
- sep = ' '
381
- return f'{"":>{wid}}{sep}│ └────── {port:<12} {serial}'
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}'
382
518
 
383
519
  def draw_screen(self):
384
520
  """Draw the main device list"""
@@ -388,6 +524,12 @@ class MainScreen(DiskWipeScreen):
388
524
  return not app.filter or app.filter.search(name)
389
525
 
390
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)
391
533
 
392
534
  # First pass: process jobs and collect visible partitions
393
535
  visible_partitions = []
@@ -474,6 +616,9 @@ class MainScreen(DiskWipeScreen):
474
616
  else:
475
617
  verify_detail = verify_result
476
618
 
619
+ # Structured logging
620
+ Utils.log_wipe_structured(app.partitions, partition, partition.job)
621
+ # Legacy text log (keep for compatibility)
477
622
  Utils.log_wipe(partition.name, partition.size_bytes, 'Vrfy', result, elapsed,
478
623
  uuid=partition.uuid, verify_result=verify_detail)
479
624
  app.job_cnt -= 1
@@ -483,6 +628,7 @@ class MainScreen(DiskWipeScreen):
483
628
  partition.state = partition.dflt
484
629
  partition.job = None
485
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
486
632
  else:
487
633
  # Wipe job completed (with or without auto-verify)
488
634
  # Check if stopped during verify phase (after successful write)
@@ -515,12 +661,15 @@ class MainScreen(DiskWipeScreen):
515
661
  # Log the wipe operation
516
662
  elapsed = time.monotonic() - partition.job.start_mono
517
663
  result = 'stopped' if partition.job.do_abort else 'completed'
518
- # Extract base mode (remove '+V' suffix if present)
519
- 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')
520
666
  # Calculate percentage if stopped
521
667
  pct = None
522
668
  if partition.job.do_abort and partition.job.total_size > 0:
523
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)
524
673
  # Only pass label/fstype for stopped wipes (not completed)
525
674
  if result == 'stopped':
526
675
  Utils.log_wipe(partition.name, partition.size_bytes, mode, result, elapsed,
@@ -553,37 +702,51 @@ class MainScreen(DiskWipeScreen):
553
702
  else:
554
703
  verify_detail = verify_result
555
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
556
707
  Utils.log_wipe(partition.name, partition.size_bytes, 'Vrfy', result, verify_elapsed,
557
708
  uuid=partition.uuid, verify_result=verify_detail)
558
709
 
559
710
  if partition.job.exception:
560
- app.win.stop_curses()
561
- print('\n\n\n========== ALERT =========\n')
562
- print(f' FAILED: wipe {repr(partition.name)}')
563
- print(partition.job.exception)
564
- input('\n\n===== Press ENTER to continue ====> ')
565
- app.win._start_curses()
711
+ app.win.alert(
712
+ message=f'FAILED: wipe {repr(partition.name)}\n{partition.job.exception}',
713
+ title='ALERT'
714
+ )
566
715
 
567
716
  partition.job = None
568
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
569
719
  if partition.job:
570
720
  elapsed, pct, rate, until = partition.job.get_status()
571
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
+
572
727
  # FLUSH goes in mounts column, not state
573
728
  if pct.startswith('FLUSH'):
574
729
  partition.state = partition.dflt # Keep default state (s, W, etc)
575
730
  if rate and until:
576
- partition.mounts = [f'{pct} {elapsed} -{until} {rate}']
731
+ partition.mounts = [f'{task_name} {pct} {elapsed} -{until} {rate}']
577
732
  else:
578
- partition.mounts = [f'{pct} {elapsed}']
733
+ partition.mounts = [f'{task_name} {pct} {elapsed}']
579
734
  else:
580
735
  partition.state = pct
581
- slowdown = partition.job.max_slowdown_ratio # temp?
582
- stall = partition.job.max_stall_secs # temp
583
- 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)]
584
747
 
585
748
  if partition.parent and partition.parent in app.partitions and (
586
- app.partitions[partition.parent].state == 'Lock'):
749
+ app.partitions[partition.parent].state == 'Blk'):
587
750
  continue
588
751
 
589
752
  if wanted(name) or partition.job:
@@ -619,37 +782,52 @@ class MainScreen(DiskWipeScreen):
619
782
  ctx = Context(genre='disk' if partition.parent is None else 'partition',
620
783
  partition=partition)
621
784
  app.win.add_body(partition.line, attr=attr, context=ctx)
622
- if partition.parent is None and app.opts.port_serial:
623
- line = self._port_serial_line(partition.port, partition.serial)
624
- app.win.add_body(line, attr=attr, context=Context(genre='DECOR'))
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'))
625
795
 
626
796
  # Show inline confirmation prompt if this is the partition being confirmed
627
- if app.confirmation.active and app.confirmation.partition_name == partition.name:
797
+ if app.confirmation.active and app.confirmation.identity == partition.name:
628
798
  # Build confirmation message
629
- if app.confirmation.confirm_type == 'wipe':
630
- msg = f'⚠️ WIPE {partition.name} ({Utils.human(partition.size_bytes)})'
799
+ if app.confirmation.action_type == 'wipe':
800
+ msg = f'⚠️ WIPE {partition.name}'
631
801
  else: # verify
632
- msg = f'⚠️ VERIFY {partition.name} ({Utils.human(partition.size_bytes)}) - writes marker'
633
-
634
- # Add mode-specific prompt
635
- if app.confirmation.mode == 'Y':
636
- msg += " - Press 'Y' or ESC"
637
- elif app.confirmation.mode == 'y':
638
- msg += " - Press 'y' or ESC"
639
- elif app.confirmation.mode == 'YES':
640
- msg += f" - Type 'YES': {app.confirmation.input_buffer}_"
641
- elif app.confirmation.mode == 'yes':
642
- msg += f" - Type 'yes': {app.confirmation.input_buffer}_"
643
- elif app.confirmation.mode == 'device':
644
- 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}): "
645
812
 
646
813
  # Position message at fixed column (reduced from 28 to 20)
647
814
  msg = ' ' * 20 + msg
648
815
 
649
- # Add confirmation message as DECOR (non-pickable)
816
+ # Add confirmation message base as DECOR (non-pickable)
650
817
  app.win.add_body(msg, attr=cs.color_pair(Theme.DANGER) | cs.A_BOLD,
651
818
  context=Context(genre='DECOR'))
652
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
+
653
831
  app.win.add_fancy_header(app.get_keys_line(), mode=app.opts.header_mode)
654
832
 
655
833
  app.win.add_header(app.dev_info.head_str, attr=cs.A_DIM)
@@ -718,7 +896,12 @@ class MainScreen(DiskWipeScreen):
718
896
  part = ctx.partition
719
897
  if app.test_state(part, to='0%'):
720
898
  self.clear_hotswap_marker(part)
721
- 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)
722
905
  app.win.passthrough_mode = True
723
906
 
724
907
  def verify_ACTION(self):
@@ -736,7 +919,8 @@ class MainScreen(DiskWipeScreen):
736
919
  is_unmarked = part.state == '-' and (not part.parent or not part.fstype)
737
920
  if is_unmarked:
738
921
  # Require confirmation for unmarked partitions
739
- app.confirmation.start('verify', part.name, app.opts.confirmation)
922
+ app.confirmation.start(action_type='verify',
923
+ identity=part.name, mode="yes")
740
924
  app.win.passthrough_mode = True
741
925
  else:
742
926
  # Marked partition - proceed directly
@@ -747,6 +931,39 @@ class MainScreen(DiskWipeScreen):
747
931
  part.size_bytes, opts=app.opts)
748
932
  app.job_cnt += 1
749
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
+
750
967
  def stop_ACTION(self):
751
968
  """Handle 's' key"""
752
969
  app = self.app
@@ -758,6 +975,7 @@ class MainScreen(DiskWipeScreen):
758
975
  if part.job and not part.job.done:
759
976
  part.job.do_abort = True
760
977
 
978
+
761
979
  def stop_all_ACTION(self):
762
980
  """Handle 'S' key"""
763
981
  app = self.app
@@ -766,14 +984,14 @@ class MainScreen(DiskWipeScreen):
766
984
  if part.job and not part.job.done:
767
985
  part.job.do_abort = True
768
986
 
769
- def lock_ACTION(self):
770
- """Handle 'l' key"""
987
+ def block_ACTION(self):
988
+ """Handle 'b' key"""
771
989
  app = self.app
772
990
  ctx = app.win.get_picked_context()
773
991
  if ctx and hasattr(ctx, 'partition'):
774
992
  part = ctx.partition
775
993
  self.clear_hotswap_marker(part)
776
- app.set_state(part, 'Unlk' if part.state == 'Lock' else 'Lock')
994
+ app.set_state(part, 'Unbl' if part.state == 'Blk' else 'Blk')
777
995
 
778
996
  def help_ACTION(self):
779
997
  """Handle '?' key - push help screen"""
@@ -810,81 +1028,178 @@ class HelpScreen(DiskWipeScreen):
810
1028
 
811
1029
 
812
1030
  class HistoryScreen(DiskWipeScreen):
813
- """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
+ )
814
1087
 
815
1088
  def draw_screen(self):
816
- """Draw the history screen"""
1089
+ """Draw the history screen with structured log entries"""
817
1090
  app = self.app
818
- # spinner = self.get_spinner()
1091
+ win = app.win
1092
+ win.set_pick_mode(True)
819
1093
 
820
- 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}')
821
1136
 
822
- # Add header
823
- app.win.add_header('WIPE HISTORY (newest first)', attr=cs.A_BOLD)
824
- app.win.add_header(' Press ESC to return', resume=True)
1137
+ # Build display
1138
+ for entry in self.filtered_entries:
1139
+ timestamp = entry.timestamp
825
1140
 
826
- # Read and display log file in reverse order
827
- log_path = Utils.get_log_path()
828
- if log_path.exists():
829
- try:
830
- with open(log_path, 'r', encoding='utf-8') as f:
831
- lines = f.readlines()
832
-
833
- # Show in reverse order (newest first)
834
- for line in reversed(lines):
835
- app.win.put_body(line.rstrip())
836
- except Exception as e:
837
- app.win.put_body(f'Error reading log: {e}')
838
- else:
839
- app.win.put_body('No wipe history found.')
840
- app.win.put_body('')
841
- app.win.put_body(f'Log file will be created at: {log_path}')
1141
+ # Get display summary from entry
1142
+ summary = entry.display_summary
842
1143
 
1144
+ # Format timestamp (just date and time)
1145
+ timestamp_display = timestamp[:19] # YYYY-MM-DD HH:MM:SS
843
1146
 
844
- class ThemeScreen(DiskWipeScreen):
845
- """Theme preview screen showing all available themes with color examples"""
846
- prev_theme = ""
1147
+ level = entry.level
847
1148
 
848
- def draw_screen(self):
849
- """Draw the theme screen with color examples for all themes"""
850
- 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 ""
851
1151
 
852
- 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
853
1200
 
854
- # Add header showing current theme
855
-
856
- app.win.add_header(f'COLOR THEME: {app.opts.theme:^18}', attr=cs.A_BOLD)
857
- app.win.add_header(' Press [t] to cycle themes, ESC to return', resume=True)
858
-
859
- # Color purpose labels
860
- color_labels = [
861
- (Theme.DANGER, 'DANGER', 'Destructive operations (wipe prompts)'),
862
- (Theme.SUCCESS, 'SUCCESS', 'Completed operations'),
863
- (Theme.OLD_SUCCESS, 'OLD_SUCCESS', 'Older Completed operations'),
864
- (Theme.WARNING, 'WARNING', 'Caution/stopped states'),
865
- (Theme.INFO, 'INFO', 'Informational states'),
866
- (Theme.EMPHASIS, 'EMPHASIS', 'Emphasized text'),
867
- (Theme.ERROR, 'ERROR', 'Errors'),
868
- (Theme.PROGRESS, 'PROGRESS', 'Progress indicators'),
869
- (Theme.HOTSWAP, 'HOTSWAP', 'Newly inserted devices'),
870
- ]
871
-
872
- # Display color examples for current theme
873
- theme_info = Theme.THEMES[app.opts.theme]
874
- _ = theme_info.get('name', app.opts.theme)
875
-
876
- # Show color examples for this theme
877
- for color_id, label, description in color_labels:
878
- # Create line with colored block and description
879
- line = f'{label:12} ████████ {description}'
880
- attr = cs.color_pair(color_id)
881
- app.win.add_body(line, attr=attr)
882
-
883
- def spin_theme_ACTION(self):
884
- """ TBD """
885
- vals = Theme.list_all()
886
- value = Theme.get_current()
887
- idx = vals.index(value) if value in vals else -1
888
- value = vals[(idx+1) % len(vals)] # choose next
889
- Theme.set(value)
890
- 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