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/DeviceInfo.py +291 -59
- dwipe/DiskWipe.py +497 -172
- dwipe/DrivePreChecker.py +90 -0
- dwipe/FirmwareWipeTask.py +370 -0
- dwipe/LsblkMonitor.py +124 -0
- dwipe/PersistentState.py +28 -18
- dwipe/Prereqs.py +84 -0
- dwipe/StructuredLogger.py +643 -0
- dwipe/ToolManager.py +618 -0
- 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.0.dist-info → dwipe-2.0.2.dist-info}/METADATA +69 -30
- dwipe-2.0.2.dist-info/RECORD +21 -0
- dwipe/WipeJobFuture.py +0 -245
- dwipe-2.0.0.dist-info/RECORD +0 -13
- {dwipe-2.0.0.dist-info → dwipe-2.0.2.dist-info}/WHEEL +0 -0
- {dwipe-2.0.0.dist-info → dwipe-2.0.2.dist-info}/entry_points.txt +0 -0
- {dwipe-2.0.0.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,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
|
-
|
|
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='
|
|
233
|
-
actions['
|
|
234
|
-
if self.test_state(part, to='
|
|
235
|
-
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'
|
|
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('
|
|
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=['
|
|
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('
|
|
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
|
-
|
|
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
|
-
|
|
359
|
-
self.persistent_state.save_updated_opts(self.opts)
|
|
360
|
-
self.persistent_state.sync()
|
|
447
|
+
check_devices_mono = time.monotonic()
|
|
361
448
|
|
|
362
|
-
|
|
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
|
-
#
|
|
512
|
-
mode =
|
|
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.
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
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
|
-
|
|
575
|
-
|
|
576
|
-
|
|
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 == '
|
|
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.
|
|
797
|
+
if app.confirmation.active and app.confirmation.identity == partition.name:
|
|
618
798
|
# Build confirmation message
|
|
619
|
-
if app.confirmation.
|
|
620
|
-
msg = f'⚠️ WIPE {partition.name}
|
|
799
|
+
if app.confirmation.action_type == 'wipe':
|
|
800
|
+
msg = f'⚠️ WIPE {partition.name}'
|
|
621
801
|
else: # verify
|
|
622
|
-
msg = f'⚠️ VERIFY {partition.name}
|
|
623
|
-
|
|
624
|
-
# Add mode-specific prompt
|
|
625
|
-
if app.confirmation.mode == '
|
|
626
|
-
msg += " -
|
|
627
|
-
elif app.confirmation.mode == '
|
|
628
|
-
msg += " -
|
|
629
|
-
elif app.confirmation.mode == '
|
|
630
|
-
|
|
631
|
-
|
|
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
|
-
|
|
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',
|
|
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
|
|
760
|
-
"""Handle '
|
|
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, '
|
|
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
|
|
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
|
-
|
|
1091
|
+
win = app.win
|
|
1092
|
+
win.set_pick_mode(True)
|
|
809
1093
|
|
|
810
|
-
|
|
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
|
-
#
|
|
813
|
-
|
|
814
|
-
|
|
1137
|
+
# Build display
|
|
1138
|
+
for entry in self.filtered_entries:
|
|
1139
|
+
timestamp = entry.timestamp
|
|
815
1140
|
|
|
816
|
-
|
|
817
|
-
|
|
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
|
-
|
|
835
|
-
"""Theme preview screen showing all available themes with color examples"""
|
|
836
|
-
prev_theme = ""
|
|
1147
|
+
level = entry.level
|
|
837
1148
|
|
|
838
|
-
|
|
839
|
-
|
|
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
|
-
|
|
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
|
-
|
|
845
|
-
|
|
846
|
-
app
|
|
847
|
-
|
|
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
|