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/DeviceInfo.py +183 -52
- dwipe/DiskWipe.py +495 -180
- dwipe/DrivePreChecker.py +90 -0
- dwipe/FirmwareWipeTask.py +370 -0
- dwipe/LsblkMonitor.py +124 -0
- dwipe/PersistentState.py +26 -8
- dwipe/Prereqs.py +84 -0
- dwipe/StructuredLogger.py +643 -0
- dwipe/ToolManager.py +235 -254
- dwipe/Utils.py +108 -0
- dwipe/VerifyTask.py +410 -0
- dwipe/WipeJob.py +613 -165
- dwipe/WipeTask.py +148 -0
- dwipe/WriteTask.py +402 -0
- dwipe/main.py +14 -9
- {dwipe-2.0.1.dist-info → dwipe-2.0.2.dist-info}/METADATA +69 -33
- dwipe-2.0.2.dist-info/RECORD +21 -0
- dwipe/WipeJobFuture.py +0 -245
- dwipe-2.0.1.dist-info/RECORD +0 -14
- {dwipe-2.0.1.dist-info → dwipe-2.0.2.dist-info}/WHEEL +0 -0
- {dwipe-2.0.1.dist-info → dwipe-2.0.2.dist-info}/entry_points.txt +0 -0
- {dwipe-2.0.1.dist-info → dwipe-2.0.2.dist-info}/licenses/LICENSE +0 -0
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
|
|
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
|
|
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.
|
|
86
|
-
part = self.partitions[self.confirmation.
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
self.
|
|
93
|
-
|
|
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.
|
|
101
|
-
part = self.partitions[self.confirmation.
|
|
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
|
|
121
|
-
if result and to in ('
|
|
122
|
-
self.persistent_state.set_device_locked(ns, to == '
|
|
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.
|
|
221
|
+
if self.confirmation.action_type == 'wipe':
|
|
150
222
|
self._start_wipe()
|
|
151
|
-
elif self.confirmation.
|
|
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='
|
|
234
|
-
actions['
|
|
235
|
-
if self.test_state(part, to='
|
|
236
|
-
actions['
|
|
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=[
|
|
300
|
-
spin.add_key('slowdown_stop', '
|
|
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=['
|
|
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('
|
|
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
|
-
|
|
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
|
-
|
|
361
|
-
self.persistent_state.save_updated_opts(self.opts)
|
|
362
|
-
self.persistent_state.sync()
|
|
447
|
+
check_devices_mono = time.monotonic()
|
|
363
448
|
|
|
364
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
#
|
|
519
|
-
mode =
|
|
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.
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
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
|
-
|
|
582
|
-
|
|
583
|
-
|
|
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 == '
|
|
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
|
-
|
|
624
|
-
|
|
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.
|
|
797
|
+
if app.confirmation.active and app.confirmation.identity == partition.name:
|
|
628
798
|
# Build confirmation message
|
|
629
|
-
if app.confirmation.
|
|
630
|
-
msg = f'⚠️ WIPE {partition.name}
|
|
799
|
+
if app.confirmation.action_type == 'wipe':
|
|
800
|
+
msg = f'⚠️ WIPE {partition.name}'
|
|
631
801
|
else: # verify
|
|
632
|
-
msg = f'⚠️ VERIFY {partition.name}
|
|
633
|
-
|
|
634
|
-
# Add mode-specific prompt
|
|
635
|
-
if app.confirmation.mode == '
|
|
636
|
-
msg += " -
|
|
637
|
-
elif app.confirmation.mode == '
|
|
638
|
-
msg += " -
|
|
639
|
-
elif app.confirmation.mode == '
|
|
640
|
-
|
|
641
|
-
|
|
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
|
-
|
|
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',
|
|
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
|
|
770
|
-
"""Handle '
|
|
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, '
|
|
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
|
|
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
|
-
|
|
1091
|
+
win = app.win
|
|
1092
|
+
win.set_pick_mode(True)
|
|
819
1093
|
|
|
820
|
-
|
|
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
|
-
#
|
|
823
|
-
|
|
824
|
-
|
|
1137
|
+
# Build display
|
|
1138
|
+
for entry in self.filtered_entries:
|
|
1139
|
+
timestamp = entry.timestamp
|
|
825
1140
|
|
|
826
|
-
|
|
827
|
-
|
|
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
|
-
|
|
845
|
-
"""Theme preview screen showing all available themes with color examples"""
|
|
846
|
-
prev_theme = ""
|
|
1147
|
+
level = entry.level
|
|
847
1148
|
|
|
848
|
-
|
|
849
|
-
|
|
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
|
-
|
|
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
|
-
|
|
855
|
-
|
|
856
|
-
app
|
|
857
|
-
|
|
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
|