dwipe 2.0.1__py3-none-any.whl → 3.0.0__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/DeviceChangeMonitor.py +244 -0
- dwipe/DeviceInfo.py +703 -177
- dwipe/DeviceWorker.py +566 -0
- dwipe/DiskWipe.py +953 -214
- dwipe/DrivePreChecker.py +203 -0
- dwipe/FirmwareWipeTask.py +865 -0
- dwipe/NvmeTool.py +225 -0
- dwipe/PersistentState.py +45 -16
- dwipe/Prereqs.py +84 -0
- dwipe/SataTool.py +499 -0
- dwipe/StructuredLogger.py +644 -0
- dwipe/Tunables.py +62 -0
- dwipe/Utils.py +298 -3
- dwipe/VerifyTask.py +412 -0
- dwipe/WipeJob.py +631 -171
- dwipe/WipeTask.py +150 -0
- dwipe/WriteTask.py +402 -0
- dwipe/main.py +34 -9
- dwipe-3.0.0.dist-info/METADATA +566 -0
- dwipe-3.0.0.dist-info/RECORD +24 -0
- dwipe/ToolManager.py +0 -637
- dwipe/WipeJobFuture.py +0 -245
- dwipe-2.0.1.dist-info/METADATA +0 -410
- dwipe-2.0.1.dist-info/RECORD +0 -14
- {dwipe-2.0.1.dist-info → dwipe-3.0.0.dist-info}/WHEEL +0 -0
- {dwipe-2.0.1.dist-info → dwipe-3.0.0.dist-info}/entry_points.txt +0 -0
- {dwipe-2.0.1.dist-info → dwipe-3.0.0.dist-info}/licenses/LICENSE +0 -0
dwipe/DiskWipe.py
CHANGED
|
@@ -5,21 +5,28 @@ 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
|
|
18
|
+
from datetime import datetime
|
|
15
19
|
from console_window import (ConsoleWindow, ConsoleWindowOpts, OptionSpinner,
|
|
16
20
|
IncrementalSearchBar, InlineConfirmation, Theme,
|
|
17
21
|
Screen, ScreenStack, Context)
|
|
18
22
|
|
|
19
23
|
from .WipeJob import WipeJob
|
|
20
24
|
from .DeviceInfo import DeviceInfo
|
|
25
|
+
from .DeviceWorker import ProbeState
|
|
21
26
|
from .Utils import Utils
|
|
22
27
|
from .PersistentState import PersistentState
|
|
28
|
+
from .StructuredLogger import StructuredLogger
|
|
29
|
+
from .DeviceChangeMonitor import DeviceChangeMonitor
|
|
23
30
|
|
|
24
31
|
# Screen constants
|
|
25
32
|
MAIN_ST = 0
|
|
@@ -33,10 +40,26 @@ class DiskWipe:
|
|
|
33
40
|
"""Main application controller and UI manager"""
|
|
34
41
|
singleton = None
|
|
35
42
|
|
|
36
|
-
def __init__(self, opts=None):
|
|
43
|
+
def __init__(self, opts=None, persistent_state=None):
|
|
37
44
|
DiskWipe.singleton = self
|
|
38
|
-
self.opts = opts if opts else SimpleNamespace(debug=0
|
|
39
|
-
|
|
45
|
+
self.opts = opts if opts else SimpleNamespace(debug=0)
|
|
46
|
+
# Use provided persistent_state or create a new one
|
|
47
|
+
self.persistent_state = persistent_state if persistent_state else PersistentState()
|
|
48
|
+
# Set defaults for command-line options (only if not provided)
|
|
49
|
+
if not hasattr(self.opts, 'wipe_mode'):
|
|
50
|
+
self.opts.wipe_mode = '+V'
|
|
51
|
+
if not hasattr(self.opts, 'passes'):
|
|
52
|
+
self.opts.passes = 1
|
|
53
|
+
if not hasattr(self.opts, 'verify_pct'):
|
|
54
|
+
self.opts.verify_pct = 1
|
|
55
|
+
if not hasattr(self.opts, 'port_serial'):
|
|
56
|
+
self.opts.port_serial = 'Auto'
|
|
57
|
+
if not hasattr(self.opts, 'slowdown_stop'):
|
|
58
|
+
self.opts.slowdown_stop = 64
|
|
59
|
+
if not hasattr(self.opts, 'stall_timeout'):
|
|
60
|
+
self.opts.stall_timeout = 60
|
|
61
|
+
if not hasattr(self.opts, 'dense'):
|
|
62
|
+
self.opts.dense = False
|
|
40
63
|
self.mounts_lines = None
|
|
41
64
|
self.partitions = {} # a dict of namespaces keyed by name
|
|
42
65
|
self.wids = None
|
|
@@ -65,40 +88,137 @@ class DiskWipe:
|
|
|
65
88
|
on_cancel=self._on_filter_cancel
|
|
66
89
|
)
|
|
67
90
|
|
|
68
|
-
# Initialize persistent state
|
|
69
|
-
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
|
-
|
|
83
91
|
def _start_wipe(self):
|
|
84
92
|
"""Start the wipe job after confirmation"""
|
|
85
|
-
if self.confirmation.
|
|
86
|
-
part = self.partitions[self.confirmation.
|
|
93
|
+
if self.confirmation.identity and self.confirmation.identity in self.partitions:
|
|
94
|
+
part = self.partitions[self.confirmation.identity]
|
|
87
95
|
# Clear any previous verify failure message when starting wipe
|
|
88
96
|
if hasattr(part, 'verify_failed_msg'):
|
|
89
97
|
delattr(part, 'verify_failed_msg')
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
self.
|
|
98
|
+
|
|
99
|
+
# Get the wipe type from user's choice
|
|
100
|
+
# ConsoleWindow already canonicalizes case, just strip the '*' recommendation marker
|
|
101
|
+
wipe_type = self.confirmation.input_buffer.strip().rstrip('*')
|
|
102
|
+
|
|
103
|
+
# Check if it's a firmware wipe
|
|
104
|
+
if wipe_type not in ('Zero', 'Rand'):
|
|
105
|
+
# Firmware wipe - check if it's available
|
|
106
|
+
if not part.hw_caps or wipe_type not in part.hw_caps:
|
|
107
|
+
part.mounts = [f'⚠ Firmware wipe {wipe_type} not available']
|
|
108
|
+
self.confirmation.cancel()
|
|
109
|
+
self.win.passthrough_mode = False
|
|
110
|
+
return
|
|
111
|
+
|
|
112
|
+
# Get command args for this wipe type
|
|
113
|
+
from .DrivePreChecker import DrivePreChecker
|
|
114
|
+
command_args = DrivePreChecker.get_wipe_command_args(wipe_type)
|
|
115
|
+
|
|
116
|
+
# Import firmware task classes
|
|
117
|
+
from .FirmwareWipeTask import (NvmeWipeTask, SataWipeTask,
|
|
118
|
+
StandardPrecheckTask,
|
|
119
|
+
FirmwarePreVerifyTask, FirmwarePostVerifyTask)
|
|
120
|
+
|
|
121
|
+
# Determine task type based on device name
|
|
122
|
+
if part.name.startswith('nvme'):
|
|
123
|
+
task_class = NvmeWipeTask
|
|
124
|
+
else:
|
|
125
|
+
task_class = SataWipeTask
|
|
126
|
+
|
|
127
|
+
# Create firmware task
|
|
128
|
+
fw_task = task_class(
|
|
129
|
+
device_path=f'/dev/{part.name}',
|
|
130
|
+
total_size=part.size_bytes,
|
|
131
|
+
opts=self.opts,
|
|
132
|
+
command_args=command_args,
|
|
133
|
+
wipe_name=wipe_type
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
# Store wipe type for logging
|
|
137
|
+
part.wipe_type = wipe_type
|
|
138
|
+
|
|
139
|
+
# Build task list with precheck, pre/post firmware verification
|
|
140
|
+
# (Software verify is not supported for firmware wipes)
|
|
141
|
+
precheck = StandardPrecheckTask(
|
|
142
|
+
device_path=f'/dev/{part.name}',
|
|
143
|
+
total_size=part.size_bytes,
|
|
144
|
+
opts=self.opts,
|
|
145
|
+
selected_wipe_type=wipe_type,
|
|
146
|
+
command_method=command_args
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
pre_verify = FirmwarePreVerifyTask(
|
|
150
|
+
device_path=f'/dev/{part.name}',
|
|
151
|
+
total_size=part.size_bytes,
|
|
152
|
+
opts=self.opts
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
post_verify = FirmwarePostVerifyTask(
|
|
156
|
+
device_path=f'/dev/{part.name}',
|
|
157
|
+
total_size=part.size_bytes,
|
|
158
|
+
opts=self.opts
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
tasks = [precheck, pre_verify, fw_task, post_verify]
|
|
162
|
+
|
|
163
|
+
# Create WipeJob with firmware task (and optional verify task)
|
|
164
|
+
part.job = WipeJob(
|
|
165
|
+
device_path=f'/dev/{part.name}',
|
|
166
|
+
total_size=part.size_bytes,
|
|
167
|
+
opts=self.opts,
|
|
168
|
+
tasks=tasks
|
|
169
|
+
)
|
|
170
|
+
# Firmware wipes (SATA secure erase, NVMe sanitize) write zeros
|
|
171
|
+
part.job.expected_pattern = "zeroed"
|
|
172
|
+
part.job.thread = threading.Thread(target=part.job.run_tasks)
|
|
173
|
+
part.job.thread.start()
|
|
174
|
+
|
|
175
|
+
self.job_cnt += 1
|
|
176
|
+
self.set_state(part, to='0%')
|
|
177
|
+
|
|
178
|
+
# Clear confirmation and return early
|
|
179
|
+
self.confirmation.cancel()
|
|
180
|
+
self.win.passthrough_mode = False
|
|
181
|
+
return
|
|
182
|
+
|
|
183
|
+
# Construct full wipe mode (e.g., 'Zero+V', 'Rand', etc.)
|
|
184
|
+
if self.opts.wipe_mode == '+V':
|
|
185
|
+
full_wipe_mode = wipe_type + '+V'
|
|
186
|
+
else:
|
|
187
|
+
full_wipe_mode = wipe_type
|
|
188
|
+
|
|
189
|
+
# Store wipe type for later logging
|
|
190
|
+
part.wipe_type = wipe_type
|
|
191
|
+
|
|
192
|
+
# Temporarily set the full wipe mode
|
|
193
|
+
old_wipe_mode = self.opts.wipe_mode
|
|
194
|
+
self.opts.wipe_mode = full_wipe_mode
|
|
195
|
+
|
|
196
|
+
try:
|
|
197
|
+
part.job = WipeJob.start_job(f'/dev/{part.name}',
|
|
198
|
+
part.size_bytes, opts=self.opts)
|
|
199
|
+
self.job_cnt += 1
|
|
200
|
+
self.set_state(part, to='0%')
|
|
201
|
+
part.hw_nopes, part.hw_caps = {}, {}
|
|
202
|
+
finally:
|
|
203
|
+
# Restore original wipe_mode
|
|
204
|
+
self.opts.wipe_mode = old_wipe_mode
|
|
205
|
+
|
|
94
206
|
# Clear confirmation state
|
|
95
207
|
self.confirmation.cancel()
|
|
96
208
|
self.win.passthrough_mode = False # Disable passthrough
|
|
97
209
|
|
|
98
210
|
def _start_verify(self):
|
|
99
211
|
"""Start the verify job after confirmation"""
|
|
100
|
-
if self.confirmation.
|
|
101
|
-
part = self.partitions[self.confirmation.
|
|
212
|
+
if self.confirmation.identity and self.confirmation.identity in self.partitions:
|
|
213
|
+
part = self.partitions[self.confirmation.identity]
|
|
214
|
+
|
|
215
|
+
# Safety check: Firmware wipes have built-in verification - prevent manual verify
|
|
216
|
+
if self._is_firmware_wipe_marker(part):
|
|
217
|
+
part.mounts = ['⚠ Firmware wipes have built-in verification - standard verify not allowed']
|
|
218
|
+
self.confirmation.cancel()
|
|
219
|
+
self.win.passthrough_mode = False
|
|
220
|
+
return
|
|
221
|
+
|
|
102
222
|
# Clear any previous verify failure message when starting verify
|
|
103
223
|
if hasattr(part, 'verify_failed_msg'):
|
|
104
224
|
delattr(part, 'verify_failed_msg')
|
|
@@ -117,12 +237,44 @@ class DiskWipe:
|
|
|
117
237
|
"""Set state of partition"""
|
|
118
238
|
result = self.dev_info.set_one_state(self.partitions, ns, to=to)
|
|
119
239
|
|
|
120
|
-
# Save
|
|
121
|
-
if result and to in ('
|
|
122
|
-
self.persistent_state.set_device_locked(ns, to == '
|
|
240
|
+
# Save block state changes to persistent state
|
|
241
|
+
if result and to in ('Blk', 'Unbl'):
|
|
242
|
+
self.persistent_state.set_device_locked(ns, to == 'Blk')
|
|
123
243
|
|
|
124
244
|
return result
|
|
125
245
|
|
|
246
|
+
def _is_firmware_wipe(self, part):
|
|
247
|
+
"""Check if partition has an active firmware wipe job (unstoppable)."""
|
|
248
|
+
if not part.job or part.job.done:
|
|
249
|
+
return False
|
|
250
|
+
from .FirmwareWipeTask import FirmwareWipeTask
|
|
251
|
+
current_task = getattr(part.job, 'current_task', None)
|
|
252
|
+
return current_task and isinstance(current_task, FirmwareWipeTask)
|
|
253
|
+
|
|
254
|
+
def _is_firmware_wipe_marker(self, part):
|
|
255
|
+
"""Check if partition's wipe marker indicates a firmware wipe.
|
|
256
|
+
|
|
257
|
+
Firmware wipes have modes like 'Crypto', 'Enhanced', 'Sanitize-Crypto', etc.
|
|
258
|
+
Software wipes have modes like 'Zero' or 'Rand'.
|
|
259
|
+
"""
|
|
260
|
+
if part.state not in ('s', 'W'):
|
|
261
|
+
return False
|
|
262
|
+
|
|
263
|
+
marker = WipeJob.read_marker_buffer(part.name)
|
|
264
|
+
if not marker:
|
|
265
|
+
return False
|
|
266
|
+
|
|
267
|
+
# Check if mode is a firmware wipe (not 'Zero' or 'Rand')
|
|
268
|
+
mode = getattr(marker, 'mode', None)
|
|
269
|
+
return mode and mode not in ('Zero', 'Rand')
|
|
270
|
+
|
|
271
|
+
def _has_any_firmware_wipes(self):
|
|
272
|
+
"""Check if any firmware wipes are currently running."""
|
|
273
|
+
for part in self.partitions.values():
|
|
274
|
+
if self._is_firmware_wipe(part):
|
|
275
|
+
return True
|
|
276
|
+
return False
|
|
277
|
+
|
|
126
278
|
def do_key(self, key):
|
|
127
279
|
"""Handle keyboard input"""
|
|
128
280
|
if self.exit_when_no_jobs:
|
|
@@ -137,6 +289,13 @@ class DiskWipe:
|
|
|
137
289
|
if not key:
|
|
138
290
|
return True
|
|
139
291
|
|
|
292
|
+
# Handle search bar input
|
|
293
|
+
if self.stack.curr.num == LOG_ST:
|
|
294
|
+
screen_obj = self.stack.get_curr_obj()
|
|
295
|
+
if screen_obj.search_bar.is_active:
|
|
296
|
+
if screen_obj.search_bar.handle_key(key):
|
|
297
|
+
return None # key handled by search bar
|
|
298
|
+
|
|
140
299
|
# Handle filter bar input
|
|
141
300
|
if self.filter_bar.is_active:
|
|
142
301
|
if self.filter_bar.handle_key(key):
|
|
@@ -146,9 +305,9 @@ class DiskWipe:
|
|
|
146
305
|
if self.confirmation.active:
|
|
147
306
|
result = self.confirmation.handle_key(key)
|
|
148
307
|
if result == 'confirmed':
|
|
149
|
-
if self.confirmation.
|
|
308
|
+
if self.confirmation.action_type == 'wipe':
|
|
150
309
|
self._start_wipe()
|
|
151
|
-
elif self.confirmation.
|
|
310
|
+
elif self.confirmation.action_type == 'verify':
|
|
152
311
|
self._start_verify()
|
|
153
312
|
elif result == 'cancelled':
|
|
154
313
|
self.confirmation.cancel()
|
|
@@ -181,17 +340,7 @@ class DiskWipe:
|
|
|
181
340
|
line += ' [S]top' if self.job_cnt > 0 else ''
|
|
182
341
|
line = f'{line:<20} '
|
|
183
342
|
line += self.filter_bar.get_display_string(prefix=' /') or ' /'
|
|
184
|
-
|
|
185
|
-
line += f' [m]ode={self.opts.wipe_mode}'
|
|
186
|
-
# Show passes spinner with key
|
|
187
|
-
line += f' [P]ass={self.opts.passes}'
|
|
188
|
-
# Show verification percentage spinner with key
|
|
189
|
-
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'
|
|
194
|
-
line += ' [h]ist [t]heme ?:help [q]uit'
|
|
343
|
+
line += ' [r]escan [h]ist [t]heme ?:help [q]uit'
|
|
195
344
|
return line[1:]
|
|
196
345
|
|
|
197
346
|
def get_actions(self, part):
|
|
@@ -202,20 +351,27 @@ class DiskWipe:
|
|
|
202
351
|
part = ctx.partition
|
|
203
352
|
name = part.name
|
|
204
353
|
self.pick_is_running = bool(part.job)
|
|
205
|
-
|
|
354
|
+
# Show stop action only for non-firmware wipes
|
|
355
|
+
if self.test_state(part, to='STOP') and not self._is_firmware_wipe(part):
|
|
206
356
|
actions['s'] = 'stop'
|
|
207
357
|
elif self.test_state(part, to='0%'):
|
|
208
358
|
actions['w'] = 'wipe'
|
|
359
|
+
# DEL only for whole disks that are SATA or NVMe
|
|
360
|
+
if part.parent is None and part.name[:2] in ('sd', 'hd', 'nv'):
|
|
361
|
+
actions['DEL'] = 'DEL'
|
|
209
362
|
# Can verify:
|
|
210
|
-
# 1. Anything with wipe markers (states 's' or 'W')
|
|
363
|
+
# 1. Anything with wipe markers (states 's' or 'W') - EXCEPT firmware wipes
|
|
211
364
|
# 2. Unmarked whole disks (no parent, state '-' or '^') WITHOUT partitions that have filesystems
|
|
212
365
|
# 3. Unmarked partitions without filesystems (has parent, state '-' or '^', no fstype)
|
|
213
366
|
# 4. Only if verify_pct > 0
|
|
214
367
|
# This prevents verifying filesystems which is nonsensical
|
|
368
|
+
# NOTE: Firmware wipes have built-in pre/post verification, so manual verify is disallowed
|
|
215
369
|
verify_pct = getattr(self.opts, 'verify_pct', 0)
|
|
216
370
|
if not part.job and verify_pct > 0:
|
|
217
371
|
if part.state in ('s', 'W'):
|
|
218
|
-
|
|
372
|
+
# Do NOT allow verify on firmware wipes - they have built-in verification
|
|
373
|
+
if not self._is_firmware_wipe_marker(part):
|
|
374
|
+
actions['v'] = 'verify'
|
|
219
375
|
elif part.state in ('-', '^'):
|
|
220
376
|
# For whole disks (no parent), only allow verify if no partitions have filesystems
|
|
221
377
|
# For partitions, only allow if no filesystem
|
|
@@ -230,10 +386,10 @@ class DiskWipe:
|
|
|
230
386
|
elif not part.fstype:
|
|
231
387
|
# Partition without filesystem
|
|
232
388
|
actions['v'] = 'verify'
|
|
233
|
-
if self.test_state(part, to='
|
|
234
|
-
actions['
|
|
235
|
-
if self.test_state(part, to='
|
|
236
|
-
actions['
|
|
389
|
+
if self.test_state(part, to='Blk'):
|
|
390
|
+
actions['b'] = 'block'
|
|
391
|
+
if self.test_state(part, to='Unbl'):
|
|
392
|
+
actions['b'] = 'unblk'
|
|
237
393
|
return name, actions
|
|
238
394
|
|
|
239
395
|
def _on_filter_change(self, text):
|
|
@@ -268,10 +424,57 @@ class DiskWipe:
|
|
|
268
424
|
self.prev_filter = ''
|
|
269
425
|
self.win.passthrough_mode = False
|
|
270
426
|
|
|
427
|
+
def get_hw_caps_when_needed(self):
|
|
428
|
+
""" Look for wipeable disks w/o hardware info """
|
|
429
|
+
if not self.dev_info:
|
|
430
|
+
return
|
|
431
|
+
from .DeviceWorker import ProbeState
|
|
432
|
+
for ns in self.partitions.values():
|
|
433
|
+
if ns.parent:
|
|
434
|
+
continue
|
|
435
|
+
# if ns.port.startswith('USB'):
|
|
436
|
+
# continue
|
|
437
|
+
if ns.name[:2] not in ('nv', 'sd', 'hd'):
|
|
438
|
+
continue
|
|
439
|
+
# Skip if already successfully probed (READY state) with actual results
|
|
440
|
+
# But re-probe if results are empty (unknown after wipe) or if probe FAILED
|
|
441
|
+
if ns.hw_caps_state == ProbeState.READY and (ns.hw_caps or ns.hw_nopes):
|
|
442
|
+
continue # Already have results
|
|
443
|
+
# Device must not be actively wiping (to avoid blocking)
|
|
444
|
+
if ns.job:
|
|
445
|
+
continue
|
|
446
|
+
# Don't probe mounted/blocked devices
|
|
447
|
+
if ns.state in ('Mnt', 'iMnt', 'Blk', 'iBlk'):
|
|
448
|
+
continue
|
|
449
|
+
# Reset state to PENDING to force a re-probe if needed
|
|
450
|
+
# (state stays READY even after wipe clears results)
|
|
451
|
+
if not (ns.hw_caps or ns.hw_nopes):
|
|
452
|
+
ns.hw_caps_state = ProbeState.PENDING
|
|
453
|
+
# Probe any device that might be ready (s, W, -, ^, or wipeable state)
|
|
454
|
+
self.dev_info.get_hw_capabilities(ns)
|
|
455
|
+
|
|
456
|
+
def _poll_hw_caps_updates(self):
|
|
457
|
+
"""Poll for hw_caps probe completion without full device refresh.
|
|
458
|
+
|
|
459
|
+
Called every 0.25 seconds in main loop to quickly show hw_caps
|
|
460
|
+
results as soon as worker threads complete probes.
|
|
461
|
+
"""
|
|
462
|
+
if not self.dev_info:
|
|
463
|
+
return
|
|
464
|
+
from .DeviceWorker import ProbeState
|
|
465
|
+
# Only update devices that are still probing
|
|
466
|
+
for ns in self.partitions.values():
|
|
467
|
+
if ns.parent:
|
|
468
|
+
continue
|
|
469
|
+
# Update any device that's still PENDING or PROBING
|
|
470
|
+
if ns.hw_caps_state in (ProbeState.PENDING, ProbeState.PROBING):
|
|
471
|
+
self.dev_info.get_hw_capabilities(ns)
|
|
472
|
+
|
|
271
473
|
def main_loop(self):
|
|
272
474
|
"""Main event loop"""
|
|
273
475
|
|
|
274
476
|
# Create screen instances
|
|
477
|
+
ThemeScreen = Theme.create_picker_screen(DiskWipeScreen)
|
|
275
478
|
self.screens = {
|
|
276
479
|
MAIN_ST: MainScreen(self),
|
|
277
480
|
HELP_ST: HelpScreen(self),
|
|
@@ -284,10 +487,29 @@ class DiskWipe:
|
|
|
284
487
|
head_line=True,
|
|
285
488
|
body_rows=200,
|
|
286
489
|
head_rows=4,
|
|
490
|
+
min_cols_rows=(60,10),
|
|
287
491
|
# keys=self.spin.keys ^ other_keys,
|
|
288
492
|
pick_attr=cs.A_REVERSE, # Use reverse video for pick highlighting
|
|
289
493
|
ctrl_c_terminates=False,
|
|
290
494
|
)
|
|
495
|
+
device_monitor = DeviceChangeMonitor(check_interval=1.0)
|
|
496
|
+
device_monitor.start()
|
|
497
|
+
print("Discovering devices...")
|
|
498
|
+
# Create persistent worker manager for hw_caps probing
|
|
499
|
+
# This is reused across device refreshes to allow probes to complete
|
|
500
|
+
from .DeviceWorker import DeviceWorkerManager
|
|
501
|
+
from .DrivePreChecker import DrivePreChecker
|
|
502
|
+
worker_manager = DeviceWorkerManager(DrivePreChecker())
|
|
503
|
+
# Initialize device info and pick range before first draw
|
|
504
|
+
info = DeviceInfo(opts=self.opts, persistent_state=self.persistent_state,
|
|
505
|
+
worker_manager=worker_manager)
|
|
506
|
+
self.partitions = info.assemble_partitions(self.partitions)
|
|
507
|
+
# Start probing hw_caps immediately instead of waiting for first 3s refresh
|
|
508
|
+
self.dev_info = info
|
|
509
|
+
self.get_hw_caps_when_needed()
|
|
510
|
+
if self.opts.dump_lsblk:
|
|
511
|
+
DeviceInfo.dump(self.partitions, title="after assemble_partitions")
|
|
512
|
+
exit(1)
|
|
291
513
|
|
|
292
514
|
self.win = ConsoleWindow(opts=win_opts)
|
|
293
515
|
# Initialize screen stack
|
|
@@ -296,13 +518,8 @@ class DiskWipe:
|
|
|
296
518
|
spin = self.spin = OptionSpinner(stack=self.stack)
|
|
297
519
|
spin.default_obj = self.opts
|
|
298
520
|
spin.add_key('dense', 'D - dense/spaced view', vals=[False, True])
|
|
299
|
-
spin.add_key('
|
|
300
|
-
|
|
301
|
-
spin.add_key('stall_timeout', 'T - stall timeout (sec)', vals=[60, 120, 300, 600, 0,])
|
|
302
|
-
spin.add_key('verify_pct', 'V - verification %', vals=[0, 2, 5, 10, 25, 50, 100])
|
|
303
|
-
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'])
|
|
521
|
+
spin.add_key('hist_time_format', 'a - time format',
|
|
522
|
+
vals=['ago+time', 'ago', 'time'], scope=LOG_ST)
|
|
306
523
|
|
|
307
524
|
spin.add_key('quit', 'q,x - quit program', keys='qx', genre='action')
|
|
308
525
|
spin.add_key('screen_escape', 'ESC- back one screen',
|
|
@@ -312,7 +529,11 @@ class DiskWipe:
|
|
|
312
529
|
spin.add_key('wipe', 'w - wipe device', genre='action')
|
|
313
530
|
spin.add_key('verify', 'v - verify device', genre='action')
|
|
314
531
|
spin.add_key('stop', 's - stop wipe', genre='action')
|
|
315
|
-
spin.add_key('
|
|
532
|
+
spin.add_key('block', 'b - block/unblock disk', genre='action')
|
|
533
|
+
spin.add_key('delete_device', 'DEL - remove/unbind disk from system',
|
|
534
|
+
genre='action', keys=(cs.KEY_DC))
|
|
535
|
+
spin.add_key('scan_all_devices', 'r - rescan devices and recheck capabilities',
|
|
536
|
+
genre='action', scope=MAIN_ST)
|
|
316
537
|
spin.add_key('stop_all', 'S - stop ALL wipes', genre='action')
|
|
317
538
|
spin.add_key('help', '? - show help screen', genre='action')
|
|
318
539
|
spin.add_key('history', 'h - show wipe history', genre='action')
|
|
@@ -320,78 +541,135 @@ class DiskWipe:
|
|
|
320
541
|
spin.add_key('theme_screen', 't - theme picker', genre='action', scope=MAIN_ST)
|
|
321
542
|
spin.add_key('spin_theme', 't - theme', genre='action', scope=THEME_ST)
|
|
322
543
|
spin.add_key('header_mode', '_ - header style', vals=['Underline', 'Reverse', 'Off'])
|
|
323
|
-
|
|
324
|
-
|
|
544
|
+
spin.add_key('expand', 'e - expand history entry', genre='action', scope=LOG_ST)
|
|
545
|
+
spin.add_key('copy', 'c - copy entry to clipboard', genre='action', scope=LOG_ST)
|
|
546
|
+
spin.add_key('show_keys', 'K - show keys (demo mode)', genre='action')
|
|
547
|
+
# Load theme from persistent state
|
|
548
|
+
self.opts.theme = self.persistent_state.state.get('theme', '')
|
|
325
549
|
Theme.set(self.opts.theme)
|
|
326
550
|
self.win.set_handled_keys(self.spin.keys)
|
|
327
551
|
|
|
552
|
+
# Background device change monitor started above
|
|
328
553
|
|
|
329
|
-
# self.
|
|
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)
|
|
334
|
-
self.dev_info = info
|
|
554
|
+
# self.dev_info already set during startup probe above
|
|
555
|
+
self.worker_manager = worker_manager
|
|
335
556
|
pick_range = info.get_pick_range()
|
|
336
557
|
self.win.set_pick_range(pick_range[0], pick_range[1])
|
|
337
558
|
|
|
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
559
|
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
self.persistent_state.sync()
|
|
560
|
+
check_devices_mono = time.monotonic()
|
|
561
|
+
cached_worker_state = {} # Track marker/hw_caps state to detect updates
|
|
363
562
|
|
|
364
|
-
|
|
563
|
+
try:
|
|
564
|
+
while True:
|
|
565
|
+
# Draw current screen
|
|
566
|
+
current_screen = self.screens[self.stack.curr.num]
|
|
567
|
+
current_screen.draw_screen()
|
|
568
|
+
self.win.render()
|
|
569
|
+
|
|
570
|
+
# Main thread timeout for responsive UI (background monitor checks every 0.2s)
|
|
571
|
+
_ = self.do_key(self.win.prompt(seconds=0.25))
|
|
572
|
+
|
|
573
|
+
# Handle actions using perform_actions
|
|
574
|
+
self.stack.perform_actions(spin)
|
|
575
|
+
|
|
576
|
+
# Poll for hw_caps completion without full device refresh (every 0.25s)
|
|
577
|
+
# This lets us show results quickly even though commands take 1-3 seconds
|
|
578
|
+
self._poll_hw_caps_updates()
|
|
579
|
+
|
|
580
|
+
# Check for device changes from background monitor
|
|
581
|
+
devices_changed = device_monitor.get_and_clear()
|
|
582
|
+
time_since_refresh = time.monotonic() - check_devices_mono
|
|
583
|
+
|
|
584
|
+
# Build current worker state for comparison
|
|
585
|
+
current_worker_state = {}
|
|
586
|
+
if self.worker_manager:
|
|
587
|
+
for device_name, part in self.partitions.items():
|
|
588
|
+
current_worker_state[device_name] = {
|
|
589
|
+
'marker': part.marker,
|
|
590
|
+
'hw_caps_state': part.hw_caps_state
|
|
591
|
+
}
|
|
592
|
+
# Check if worker has any updates (markers or hw_caps changes)
|
|
593
|
+
worker_has_updates = (
|
|
594
|
+
self.worker_manager.has_updates(cached_worker_state)
|
|
595
|
+
)
|
|
596
|
+
else:
|
|
597
|
+
worker_has_updates = False
|
|
598
|
+
|
|
599
|
+
if (devices_changed or worker_has_updates or
|
|
600
|
+
time_since_refresh > 3.0):
|
|
601
|
+
# Refresh if: device changes, worker updates, OR periodic (3s default)
|
|
602
|
+
info = DeviceInfo(opts=self.opts, persistent_state=self.persistent_state,
|
|
603
|
+
worker_manager=self.worker_manager)
|
|
604
|
+
self.partitions = info.assemble_partitions(self.partitions)
|
|
605
|
+
self.dev_info = info
|
|
606
|
+
# Update pick range to highlight NAME through SIZE fields
|
|
607
|
+
pick_range = info.get_pick_range()
|
|
608
|
+
self.win.set_pick_range(pick_range[0], pick_range[1])
|
|
609
|
+
# Probe hw_caps for devices that need it (only once per refresh, not every draw)
|
|
610
|
+
self.get_hw_caps_when_needed()
|
|
611
|
+
check_devices_mono = time.monotonic()
|
|
612
|
+
# Update cached state after refresh
|
|
613
|
+
cached_worker_state = current_worker_state.copy()
|
|
614
|
+
|
|
615
|
+
# Save any persistent state changes
|
|
616
|
+
self.persistent_state.save_updated_opts(self.opts)
|
|
617
|
+
self.persistent_state.sync()
|
|
618
|
+
|
|
619
|
+
self.win.clear()
|
|
620
|
+
finally:
|
|
621
|
+
# Clean up monitor thread on exit
|
|
622
|
+
device_monitor.stop()
|
|
623
|
+
# Clean up persistent worker manager
|
|
624
|
+
if hasattr(self, 'worker_manager') and self.worker_manager:
|
|
625
|
+
self.worker_manager.stop_all()
|
|
365
626
|
|
|
366
627
|
class DiskWipeScreen(Screen):
|
|
367
628
|
""" TBD """
|
|
368
629
|
app: DiskWipe
|
|
630
|
+
refresh_seconds = 3.0 # Default refresh rate for screens
|
|
369
631
|
|
|
370
632
|
def screen_escape_ACTION(self):
|
|
371
633
|
""" return to main screen """
|
|
372
634
|
self.app.stack.pop()
|
|
373
635
|
|
|
636
|
+
def show_keys_ACTION(self):
|
|
637
|
+
""" Show last key for demo"""
|
|
638
|
+
self.app.win.set_demo_mode(enabled=None) # toggle it
|
|
639
|
+
|
|
374
640
|
class MainScreen(DiskWipeScreen):
|
|
375
641
|
"""Main device list screen"""
|
|
376
642
|
|
|
377
|
-
def
|
|
643
|
+
def __init__(self, app):
|
|
644
|
+
super().__init__(app)
|
|
645
|
+
self.app = app
|
|
646
|
+
self.persist_port_serial = set()
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
def _port_serial_line(self, partition, has_children=True):
|
|
378
650
|
wids = self.app.wids
|
|
379
|
-
wid = wids.state if wids else 5
|
|
380
651
|
sep = ' '
|
|
381
|
-
|
|
652
|
+
# Sanitize port/serial - some USB bridges return strings with embedded nulls
|
|
653
|
+
port = partition.port.replace('\x00', '') if partition.port else ''
|
|
654
|
+
serial = partition.serial.replace('\x00', '') if partition.serial else ''
|
|
382
655
|
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
app = self.app
|
|
656
|
+
# Get column widths (with defaults if wids not yet initialized)
|
|
657
|
+
wid_state = wids.state if wids else 5
|
|
386
658
|
|
|
387
|
-
|
|
388
|
-
|
|
659
|
+
# Use corner └ if no children below, or vertical │ if there are children to connect to
|
|
660
|
+
connector = '│' if has_children else ' '
|
|
389
661
|
|
|
390
|
-
|
|
662
|
+
# Build base line: state padding + connector + port/serial
|
|
663
|
+
base = f'{"":>{wid_state}}{sep}{connector} └────── {port:<12} {serial}'
|
|
391
664
|
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
665
|
+
return base
|
|
666
|
+
|
|
667
|
+
def do_job_maintenance(self):
|
|
668
|
+
""" Check all the jobs in progress and advance their state
|
|
669
|
+
appropriately.
|
|
670
|
+
"""
|
|
671
|
+
app = self.app
|
|
672
|
+
for _, partition in app.partitions.items():
|
|
395
673
|
partition.line = None
|
|
396
674
|
if partition.job:
|
|
397
675
|
if partition.job.done:
|
|
@@ -474,6 +752,9 @@ class MainScreen(DiskWipeScreen):
|
|
|
474
752
|
else:
|
|
475
753
|
verify_detail = verify_result
|
|
476
754
|
|
|
755
|
+
# Structured logging
|
|
756
|
+
Utils.log_wipe_structured(app.partitions, partition, partition.job)
|
|
757
|
+
# Legacy text log (keep for compatibility)
|
|
477
758
|
Utils.log_wipe(partition.name, partition.size_bytes, 'Vrfy', result, elapsed,
|
|
478
759
|
uuid=partition.uuid, verify_result=verify_detail)
|
|
479
760
|
app.job_cnt -= 1
|
|
@@ -483,6 +764,8 @@ class MainScreen(DiskWipeScreen):
|
|
|
483
764
|
partition.state = partition.dflt
|
|
484
765
|
partition.job = None
|
|
485
766
|
partition.marker_checked = False # Reset to "dont-know" - will re-read on next scan
|
|
767
|
+
partition.marker = '' # Clear stale marker string to avoid showing old data during re-read
|
|
768
|
+
partition.monitor_marker = False # Stop monitoring verify job
|
|
486
769
|
else:
|
|
487
770
|
# Wipe job completed (with or without auto-verify)
|
|
488
771
|
# Check if stopped during verify phase (after successful write)
|
|
@@ -515,12 +798,15 @@ class MainScreen(DiskWipeScreen):
|
|
|
515
798
|
# Log the wipe operation
|
|
516
799
|
elapsed = time.monotonic() - partition.job.start_mono
|
|
517
800
|
result = 'stopped' if partition.job.do_abort else 'completed'
|
|
518
|
-
#
|
|
519
|
-
mode =
|
|
801
|
+
# Get the wipe type that was used (stored when wipe was started)
|
|
802
|
+
mode = getattr(partition, 'wipe_type', 'Unknown')
|
|
520
803
|
# Calculate percentage if stopped
|
|
521
804
|
pct = None
|
|
522
805
|
if partition.job.do_abort and partition.job.total_size > 0:
|
|
523
806
|
pct = int((partition.job.total_written / partition.job.total_size) * 100)
|
|
807
|
+
# Structured logging
|
|
808
|
+
Utils.log_wipe_structured(app.partitions, partition, partition.job, mode=mode)
|
|
809
|
+
# Legacy text log (keep for compatibility)
|
|
524
810
|
# Only pass label/fstype for stopped wipes (not completed)
|
|
525
811
|
if result == 'stopped':
|
|
526
812
|
Utils.log_wipe(partition.name, partition.size_bytes, mode, result, elapsed,
|
|
@@ -553,41 +839,106 @@ class MainScreen(DiskWipeScreen):
|
|
|
553
839
|
else:
|
|
554
840
|
verify_detail = verify_result
|
|
555
841
|
|
|
842
|
+
# Note: Structured logging for verify was already logged above as part of the wipe
|
|
843
|
+
# This is just logging the separate verify phase stats to the legacy log
|
|
556
844
|
Utils.log_wipe(partition.name, partition.size_bytes, 'Vrfy', result, verify_elapsed,
|
|
557
845
|
uuid=partition.uuid, verify_result=verify_detail)
|
|
558
846
|
|
|
559
847
|
if partition.job.exception:
|
|
560
|
-
app.win.
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
input('\n\n===== Press ENTER to continue ====> ')
|
|
565
|
-
app.win._start_curses()
|
|
848
|
+
app.win.alert(
|
|
849
|
+
message=f'FAILED: wipe {repr(partition.name)}\n{partition.job.exception}',
|
|
850
|
+
title='ALERT'
|
|
851
|
+
)
|
|
566
852
|
|
|
567
853
|
partition.job = None
|
|
568
854
|
partition.marker_checked = False # Reset to "dont-know" - will re-read on next scan
|
|
855
|
+
partition.marker = '' # Clear stale marker string to avoid showing old data during re-read
|
|
856
|
+
partition.monitor_marker = True # Start monitoring the new marker
|
|
569
857
|
if partition.job:
|
|
570
|
-
elapsed, pct, rate, until = partition.job.get_status()
|
|
858
|
+
elapsed, pct, rate, until, more_state = partition.job.get_status()
|
|
859
|
+
|
|
860
|
+
# Get task display name (Zero, Rand, Crypto, Verify, etc.)
|
|
861
|
+
task_name = ""
|
|
862
|
+
if partition.job.current_task:
|
|
863
|
+
task_name = partition.job.current_task.get_display_name()
|
|
571
864
|
|
|
572
865
|
# FLUSH goes in mounts column, not state
|
|
573
866
|
if pct.startswith('FLUSH'):
|
|
574
867
|
partition.state = partition.dflt # Keep default state (s, W, etc)
|
|
575
868
|
if rate and until:
|
|
576
|
-
partition.mounts = [f'{pct} {elapsed} -{until} {rate}']
|
|
869
|
+
partition.mounts = [f'{task_name} {pct} {elapsed} -{until} {rate}']
|
|
577
870
|
else:
|
|
578
|
-
partition.mounts = [f'{pct} {elapsed}']
|
|
871
|
+
partition.mounts = [f'{task_name} {pct} {elapsed}']
|
|
872
|
+
if more_state:
|
|
873
|
+
partition.mounts[0] += f' {more_state}'
|
|
579
874
|
else:
|
|
580
875
|
partition.state = pct
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
876
|
+
# Build progress line with task name
|
|
877
|
+
progress_parts = [task_name, elapsed, f'-{until}', rate]
|
|
878
|
+
|
|
879
|
+
# Only show slowdown/stall for WriteTask (not VerifyTask or FirmwareWipeTask)
|
|
880
|
+
from .FirmwareWipeTask import FirmwareWipeTask
|
|
881
|
+
from .WriteTask import WriteTask
|
|
882
|
+
current_task = partition.job.current_task
|
|
883
|
+
if current_task and isinstance(current_task, WriteTask) and not isinstance(current_task, FirmwareWipeTask):
|
|
884
|
+
slowdown = partition.job.max_slowdown_ratio
|
|
885
|
+
stall = partition.job.max_stall_secs
|
|
886
|
+
progress_parts.extend([f'÷{slowdown}', f'𝚫{Utils.ago_str(stall)}'])
|
|
887
|
+
|
|
888
|
+
if more_state:
|
|
889
|
+
progress_parts.append(more_state)
|
|
890
|
+
|
|
891
|
+
partition.mounts = [' '.join(progress_parts)]
|
|
584
892
|
|
|
585
893
|
if partition.parent and partition.parent in app.partitions and (
|
|
586
|
-
app.partitions[partition.parent].state
|
|
894
|
+
app.partitions[partition.parent].state in ('Blk', 'iBlk')):
|
|
587
895
|
continue
|
|
588
896
|
|
|
589
|
-
|
|
590
|
-
|
|
897
|
+
|
|
898
|
+
def draw_screen(self):
|
|
899
|
+
"""Draw the main device list"""
|
|
900
|
+
app = self.app
|
|
901
|
+
|
|
902
|
+
def wanted(name):
|
|
903
|
+
return not app.filter or app.filter.search(name)
|
|
904
|
+
|
|
905
|
+
self.do_job_maintenance()
|
|
906
|
+
|
|
907
|
+
app.win.set_pick_mode(True)
|
|
908
|
+
if app.opts.port_serial != 'Auto':
|
|
909
|
+
self.persist_port_serial = set() # name of disks
|
|
910
|
+
else: # if the disk goes away, clear persistence
|
|
911
|
+
for name in list(self.persist_port_serial):
|
|
912
|
+
if name not in app.partitions:
|
|
913
|
+
self.persist_port_serial.discard(name)
|
|
914
|
+
|
|
915
|
+
# process jobs and collect visible partitions, sorted by disk then partition
|
|
916
|
+
visible_partitions = []
|
|
917
|
+
# Get disks sorted alphabetically
|
|
918
|
+
disks = sorted([p for p in app.partitions.values() if p.parent is None],
|
|
919
|
+
key=lambda p: p.name)
|
|
920
|
+
for disk in disks:
|
|
921
|
+
if wanted(disk.name) or disk.job:
|
|
922
|
+
visible_partitions.append(disk)
|
|
923
|
+
# Add partitions for this disk, sorted alphabetically
|
|
924
|
+
parts = sorted([p for p in app.partitions.values() if p.parent == disk.name],
|
|
925
|
+
key=lambda p: p.name)
|
|
926
|
+
# If disk is directly blocked, hide children and aggregate their mounts
|
|
927
|
+
if disk.state == 'Blk':
|
|
928
|
+
# Collect all mounts from children, sort with "/" first (by name/length)
|
|
929
|
+
all_mounts = []
|
|
930
|
+
for part in parts:
|
|
931
|
+
all_mounts.extend(part.mounts)
|
|
932
|
+
# Sort: "/" first, then by name (implicitly by length since "/" is shortest)
|
|
933
|
+
all_mounts.sort(key=lambda m: (m != '/', m))
|
|
934
|
+
disk.aggregated_mounts = all_mounts
|
|
935
|
+
# Skip adding children to visible list
|
|
936
|
+
continue
|
|
937
|
+
else:
|
|
938
|
+
disk.aggregated_mounts = None
|
|
939
|
+
for part in parts:
|
|
940
|
+
if wanted(part.name) or part.job:
|
|
941
|
+
visible_partitions.append(part)
|
|
591
942
|
|
|
592
943
|
# Re-infer parent states (like 'Busy') after updating child job states
|
|
593
944
|
DeviceInfo.set_all_states(app.partitions)
|
|
@@ -618,38 +969,65 @@ class MainScreen(DiskWipeScreen):
|
|
|
618
969
|
# Create context with partition reference
|
|
619
970
|
ctx = Context(genre='disk' if partition.parent is None else 'partition',
|
|
620
971
|
partition=partition)
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
972
|
+
# For disks, underline just the alphanumeric part of fw capability text
|
|
973
|
+
fw_ul = getattr(partition, '_fw_underline', None)
|
|
974
|
+
if fw_ul:
|
|
975
|
+
ul_start, ul_end = fw_ul
|
|
976
|
+
app.win.add_body(partition.line[:ul_start], attr=attr, context=ctx)
|
|
977
|
+
ul_attr = (attr or cs.A_NORMAL) | cs.A_UNDERLINE
|
|
978
|
+
app.win.add_body(partition.line[ul_start:ul_end], attr=ul_attr, resume=True)
|
|
979
|
+
app.win.add_body(partition.line[ul_end:], attr=attr, resume=True)
|
|
980
|
+
else:
|
|
981
|
+
app.win.add_body(partition.line, attr=attr, context=ctx)
|
|
982
|
+
if partition.parent is None and app.opts.port_serial != 'Off':
|
|
983
|
+
doit = bool(app.opts.port_serial == 'On')
|
|
984
|
+
if not doit:
|
|
985
|
+
doit = bool(partition.name in self.persist_port_serial)
|
|
986
|
+
if not doit and app.test_state(partition, to='0%'):
|
|
987
|
+
doit = True
|
|
988
|
+
self.persist_port_serial.add(partition.name)
|
|
989
|
+
if doit:
|
|
990
|
+
# Check if this disk has any visible child partitions
|
|
991
|
+
has_children = partition.name in parent_last_child
|
|
992
|
+
line = self._port_serial_line(partition, has_children)
|
|
993
|
+
port_attr = (attr or cs.A_NORMAL) & ~cs.A_BOLD
|
|
994
|
+
app.win.add_body(line, attr=port_attr, context=Context(genre='DECOR'))
|
|
625
995
|
|
|
626
996
|
# Show inline confirmation prompt if this is the partition being confirmed
|
|
627
|
-
if app.confirmation.active and app.confirmation.
|
|
997
|
+
if app.confirmation.active and app.confirmation.identity == partition.name:
|
|
628
998
|
# Build confirmation message
|
|
629
|
-
if app.confirmation.
|
|
630
|
-
msg = f'⚠️
|
|
999
|
+
if app.confirmation.action_type == 'wipe':
|
|
1000
|
+
msg = f'⚠️ WIPE'
|
|
631
1001
|
else: # verify
|
|
632
|
-
msg = f'⚠️
|
|
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}_"
|
|
1002
|
+
msg = f'⚠️ VERIFY [writes marker]'
|
|
1003
|
+
|
|
1004
|
+
# Add mode-specific prompt (base message without input)
|
|
1005
|
+
if app.confirmation.mode == 'yes':
|
|
1006
|
+
msg += " - Type 'yes': "
|
|
1007
|
+
elif app.confirmation.mode == 'identity':
|
|
1008
|
+
msg += f" - Type '{partition.name}': "
|
|
1009
|
+
elif app.confirmation.mode == 'choices':
|
|
1010
|
+
choices_str = ','.join(app.confirmation.choices)
|
|
1011
|
+
msg += f" Choice ({choices_str}): "
|
|
645
1012
|
|
|
646
1013
|
# Position message at fixed column (reduced from 28 to 20)
|
|
647
|
-
msg = ' ' *
|
|
1014
|
+
msg = ' ' * 5 + msg
|
|
648
1015
|
|
|
649
|
-
# Add confirmation message as DECOR (non-pickable)
|
|
1016
|
+
# Add confirmation message base as DECOR (non-pickable)
|
|
650
1017
|
app.win.add_body(msg, attr=cs.color_pair(Theme.DANGER) | cs.A_BOLD,
|
|
651
1018
|
context=Context(genre='DECOR'))
|
|
652
1019
|
|
|
1020
|
+
# Add input or hint on same line
|
|
1021
|
+
if app.confirmation.input_buffer:
|
|
1022
|
+
# Show current input with cursor
|
|
1023
|
+
app.win.add_body(app.confirmation.input_buffer + '_',
|
|
1024
|
+
attr=cs.color_pair(Theme.DANGER) | cs.A_BOLD,
|
|
1025
|
+
resume=True)
|
|
1026
|
+
else:
|
|
1027
|
+
# Show hint in dimmed italic
|
|
1028
|
+
hint = app.confirmation.get_hint()
|
|
1029
|
+
app.win.add_body(hint, attr=cs.A_DIM | cs.A_ITALIC, resume=True)
|
|
1030
|
+
|
|
653
1031
|
app.win.add_fancy_header(app.get_keys_line(), mode=app.opts.header_mode)
|
|
654
1032
|
|
|
655
1033
|
app.win.add_header(app.dev_info.head_str, attr=cs.A_DIM)
|
|
@@ -685,9 +1063,18 @@ class MainScreen(DiskWipeScreen):
|
|
|
685
1063
|
"""Handle quit action (q or x key pressed)"""
|
|
686
1064
|
app = self.app
|
|
687
1065
|
|
|
1066
|
+
# Check for firmware wipes - cannot quit while they're running
|
|
1067
|
+
if app._has_any_firmware_wipes():
|
|
1068
|
+
# Show alert - cannot quit during firmware wipe
|
|
1069
|
+
app.filter_bar._text = 'Cannot quit: firmware wipe running'
|
|
1070
|
+
return
|
|
1071
|
+
|
|
688
1072
|
def stop_if_idle(part):
|
|
689
1073
|
if part.state[-1] == '%':
|
|
690
1074
|
if part.job and not part.job.done:
|
|
1075
|
+
# Skip firmware wipes - they cannot be stopped
|
|
1076
|
+
if app._is_firmware_wipe(part):
|
|
1077
|
+
return 1 # Count as running but don't stop
|
|
691
1078
|
part.job.do_abort = True
|
|
692
1079
|
return 1 if part.job else 0
|
|
693
1080
|
|
|
@@ -718,7 +1105,26 @@ class MainScreen(DiskWipeScreen):
|
|
|
718
1105
|
part = ctx.partition
|
|
719
1106
|
if app.test_state(part, to='0%'):
|
|
720
1107
|
self.clear_hotswap_marker(part)
|
|
721
|
-
|
|
1108
|
+
# Build choices: Zero, Rand, and any firmware wipe types
|
|
1109
|
+
# Sort all by rank (worst to best) with '*' on recommended (last) one
|
|
1110
|
+
from .DrivePreChecker import DrivePreChecker
|
|
1111
|
+
choices = ['Zero', 'Rand']
|
|
1112
|
+
fw_modes = []
|
|
1113
|
+
if part.hw_caps:
|
|
1114
|
+
# Strip '*' from hw_caps modes (already has it from display string)
|
|
1115
|
+
fw_modes = [m.strip().rstrip('*') for m in part.hw_caps.split(',')]
|
|
1116
|
+
choices.extend(fw_modes)
|
|
1117
|
+
# Use HDD rankings (prefer software wipes) ONLY if:
|
|
1118
|
+
# 1. Device reports as rotational, AND
|
|
1119
|
+
# 2. Device has no crypto-capable firmware wipes
|
|
1120
|
+
# Note: 'Enhanced' is available on HDDs too (slow overwrite, not crypto)
|
|
1121
|
+
# Only SCrypto/Crypto/FCrypto definitively indicate SSD with crypto
|
|
1122
|
+
is_rotational = getattr(part, 'is_rotational', False)
|
|
1123
|
+
has_crypto_fw = any(m in fw_modes for m in ('SCrypto', 'Crypto', 'FCrypto'))
|
|
1124
|
+
use_hdd_ranking = is_rotational and not has_crypto_fw
|
|
1125
|
+
choices = DrivePreChecker.sort_modes_by_rank(choices, is_rotational=use_hdd_ranking)
|
|
1126
|
+
app.confirmation.start(action_type='wipe',
|
|
1127
|
+
identity=part.name, mode='choices', choices=choices)
|
|
722
1128
|
app.win.passthrough_mode = True
|
|
723
1129
|
|
|
724
1130
|
def verify_ACTION(self):
|
|
@@ -730,13 +1136,20 @@ class MainScreen(DiskWipeScreen):
|
|
|
730
1136
|
# Use get_actions() to ensure we use the same logic as the header display
|
|
731
1137
|
_, actions = app.get_actions(part)
|
|
732
1138
|
if 'v' in actions:
|
|
1139
|
+
# Safety check: Prevent verification on firmware wipes
|
|
1140
|
+
# (Firmware wipes have built-in pre/post verification)
|
|
1141
|
+
if self._is_firmware_wipe_marker(part):
|
|
1142
|
+
part.mounts = ['⚠ Firmware wipes have built-in verification - standard verify not allowed']
|
|
1143
|
+
return
|
|
1144
|
+
|
|
733
1145
|
self.clear_hotswap_marker(part)
|
|
734
1146
|
# Check if this is an unmarked disk/partition (potential data loss risk)
|
|
735
1147
|
# Whole disks (no parent) or partitions without filesystems need confirmation
|
|
736
1148
|
is_unmarked = part.state == '-' and (not part.parent or not part.fstype)
|
|
737
1149
|
if is_unmarked:
|
|
738
1150
|
# Require confirmation for unmarked partitions
|
|
739
|
-
app.confirmation.start('verify',
|
|
1151
|
+
app.confirmation.start(action_type='verify',
|
|
1152
|
+
identity=part.name, mode="yes")
|
|
740
1153
|
app.win.passthrough_mode = True
|
|
741
1154
|
else:
|
|
742
1155
|
# Marked partition - proceed directly
|
|
@@ -747,8 +1160,122 @@ class MainScreen(DiskWipeScreen):
|
|
|
747
1160
|
part.size_bytes, opts=app.opts)
|
|
748
1161
|
app.job_cnt += 1
|
|
749
1162
|
|
|
1163
|
+
def scan_all_devices_ACTION(self):
|
|
1164
|
+
""" Trigger a re-scan of all devices to make the appear
|
|
1165
|
+
quicker in the list"""
|
|
1166
|
+
# Show temporary feedback
|
|
1167
|
+
self.app.win.flash('Scanning devices and rechecking firmware capabilities...', duration=0.75)
|
|
1168
|
+
|
|
1169
|
+
# SCSI host rescan (for SATA devices)
|
|
1170
|
+
base_path = '/sys/class/scsi_host'
|
|
1171
|
+
if os.path.exists(base_path):
|
|
1172
|
+
for host in os.listdir(base_path):
|
|
1173
|
+
scan_file = os.path.join(base_path, host, 'scan')
|
|
1174
|
+
if os.path.exists(scan_file):
|
|
1175
|
+
try:
|
|
1176
|
+
with open(scan_file, 'w', encoding='utf-8') as f:
|
|
1177
|
+
f.write("- - -")
|
|
1178
|
+
except Exception:
|
|
1179
|
+
pass
|
|
1180
|
+
|
|
1181
|
+
# Rebind any unbound NVMe devices
|
|
1182
|
+
self._rebind_nvme_devices()
|
|
1183
|
+
|
|
1184
|
+
# Reset hw_caps stickiness for all devices so they'll be re-probed
|
|
1185
|
+
# This allows detecting hardware state changes after sleep/wake cycles
|
|
1186
|
+
if self.app.worker_manager:
|
|
1187
|
+
for partition in self.app.partitions.values():
|
|
1188
|
+
# Queue worker to re-probe this device's capabilities
|
|
1189
|
+
if partition.parent: # only need to do whole disks
|
|
1190
|
+
continue
|
|
1191
|
+
self.app.worker_manager.request_hw_caps(partition.name)
|
|
1192
|
+
# Clear cached values so UI refreshes with new probing state
|
|
1193
|
+
partition.hw_caps = ''
|
|
1194
|
+
partition.hw_nopes = ''
|
|
1195
|
+
partition.hw_caps_state = ProbeState.PENDING
|
|
1196
|
+
|
|
1197
|
+
def delete_device_ACTION(self):
|
|
1198
|
+
""" DEL key -- Cause the OS to drop a SATA device or unbind an NVMe device
|
|
1199
|
+
so it can be replaced sooner """
|
|
1200
|
+
app = self.app
|
|
1201
|
+
ctx = app.win.get_picked_context()
|
|
1202
|
+
if ctx and hasattr(ctx, 'partition'):
|
|
1203
|
+
part = ctx.partition
|
|
1204
|
+
if not part or part.parent or not app.test_state(part, to='0%'):
|
|
1205
|
+
return
|
|
1206
|
+
# NVMe unbind - write PCI address to driver unbind file
|
|
1207
|
+
if part.name.startswith('nvme'):
|
|
1208
|
+
pci_addr = self._get_nvme_pci_address(part.name)
|
|
1209
|
+
if pci_addr:
|
|
1210
|
+
unbind_path = "/sys/bus/pci/drivers/nvme/unbind"
|
|
1211
|
+
if os.path.exists(unbind_path):
|
|
1212
|
+
try:
|
|
1213
|
+
with open(unbind_path, 'w', encoding='utf-8') as f:
|
|
1214
|
+
f.write(pci_addr)
|
|
1215
|
+
return True
|
|
1216
|
+
except Exception:
|
|
1217
|
+
pass
|
|
1218
|
+
# SATA/IDE delete - write 1 to device delete file
|
|
1219
|
+
else:
|
|
1220
|
+
path = f"/sys/block/{part.name}/device/delete"
|
|
1221
|
+
if os.path.exists(path):
|
|
1222
|
+
try:
|
|
1223
|
+
with open(path, 'w', encoding='utf-8') as f:
|
|
1224
|
+
f.write("1")
|
|
1225
|
+
return True
|
|
1226
|
+
except Exception:
|
|
1227
|
+
pass
|
|
1228
|
+
|
|
1229
|
+
def _get_nvme_pci_address(self, device_name):
|
|
1230
|
+
"""Get the full PCI address for an NVMe device (e.g., '0000:01:00.0')
|
|
1231
|
+
|
|
1232
|
+
The sysfs path may contain multiple PCI addresses (bridges), so we need
|
|
1233
|
+
the last one before /nvme/ which is the actual NVMe controller.
|
|
1234
|
+
"""
|
|
1235
|
+
try:
|
|
1236
|
+
sysfs_path = f'/sys/class/block/{device_name}'
|
|
1237
|
+
if os.path.exists(sysfs_path):
|
|
1238
|
+
real_path = os.path.realpath(sysfs_path)
|
|
1239
|
+
# Find all PCI addresses and take the last one (the NVMe controller)
|
|
1240
|
+
pci_matches = re.findall(r'(0000:[0-9a-f]{2}:[0-9a-f]{2}\.[0-9a-f])', real_path, re.I)
|
|
1241
|
+
if pci_matches:
|
|
1242
|
+
return pci_matches[-1]
|
|
1243
|
+
except Exception:
|
|
1244
|
+
pass
|
|
1245
|
+
return None
|
|
1246
|
+
|
|
1247
|
+
def _rebind_nvme_devices(self):
|
|
1248
|
+
"""Find and rebind any unbound NVMe devices.
|
|
1249
|
+
|
|
1250
|
+
Scans PCI devices for NVMe controllers (class 0x010802) that have no
|
|
1251
|
+
driver bound, and attempts to bind them to the nvme driver.
|
|
1252
|
+
"""
|
|
1253
|
+
pci_devices = '/sys/bus/pci/devices'
|
|
1254
|
+
nvme_bind = '/sys/bus/pci/drivers/nvme/bind'
|
|
1255
|
+
if not os.path.exists(pci_devices) or not os.path.exists(nvme_bind):
|
|
1256
|
+
return
|
|
1257
|
+
|
|
1258
|
+
for pci_addr in os.listdir(pci_devices):
|
|
1259
|
+
device_path = os.path.join(pci_devices, pci_addr)
|
|
1260
|
+
# Check if this is an NVMe controller (class 0x010802)
|
|
1261
|
+
class_file = os.path.join(device_path, 'class')
|
|
1262
|
+
try:
|
|
1263
|
+
with open(class_file, 'r', encoding='utf-8') as f:
|
|
1264
|
+
device_class = f.read().strip()
|
|
1265
|
+
if device_class != '0x010802':
|
|
1266
|
+
continue
|
|
1267
|
+
# Check if driver is already bound
|
|
1268
|
+
driver_link = os.path.join(device_path, 'driver')
|
|
1269
|
+
if os.path.exists(driver_link):
|
|
1270
|
+
continue
|
|
1271
|
+
# Try to bind to nvme driver
|
|
1272
|
+
with open(nvme_bind, 'w', encoding='utf-8') as f:
|
|
1273
|
+
f.write(pci_addr)
|
|
1274
|
+
except Exception:
|
|
1275
|
+
pass
|
|
1276
|
+
|
|
750
1277
|
def stop_ACTION(self):
|
|
751
|
-
"""Handle 's' key"""
|
|
1278
|
+
"""Handle 's' key - stop current wipe (but not firmware wipes)"""
|
|
752
1279
|
app = self.app
|
|
753
1280
|
if app.pick_is_running:
|
|
754
1281
|
ctx = app.win.get_picked_context()
|
|
@@ -756,24 +1283,31 @@ class MainScreen(DiskWipeScreen):
|
|
|
756
1283
|
part = ctx.partition
|
|
757
1284
|
if part.state[-1] == '%':
|
|
758
1285
|
if part.job and not part.job.done:
|
|
1286
|
+
# Skip firmware wipes - they cannot be safely stopped
|
|
1287
|
+
if app._is_firmware_wipe(part):
|
|
1288
|
+
return
|
|
759
1289
|
part.job.do_abort = True
|
|
760
1290
|
|
|
1291
|
+
|
|
761
1292
|
def stop_all_ACTION(self):
|
|
762
|
-
"""Handle 'S' key"""
|
|
1293
|
+
"""Handle 'S' key - stop all wipes (but not firmware wipes)"""
|
|
763
1294
|
app = self.app
|
|
764
1295
|
for part in app.partitions.values():
|
|
765
1296
|
if part.state[-1] == '%':
|
|
766
1297
|
if part.job and not part.job.done:
|
|
1298
|
+
# Skip firmware wipes - they cannot be safely stopped
|
|
1299
|
+
if app._is_firmware_wipe(part):
|
|
1300
|
+
continue
|
|
767
1301
|
part.job.do_abort = True
|
|
768
1302
|
|
|
769
|
-
def
|
|
770
|
-
"""Handle '
|
|
1303
|
+
def block_ACTION(self):
|
|
1304
|
+
"""Handle 'b' key"""
|
|
771
1305
|
app = self.app
|
|
772
1306
|
ctx = app.win.get_picked_context()
|
|
773
1307
|
if ctx and hasattr(ctx, 'partition'):
|
|
774
1308
|
part = ctx.partition
|
|
775
1309
|
self.clear_hotswap_marker(part)
|
|
776
|
-
app.set_state(part, '
|
|
1310
|
+
app.set_state(part, 'Unbl' if part.state == 'Blk' else 'Blk')
|
|
777
1311
|
|
|
778
1312
|
def help_ACTION(self):
|
|
779
1313
|
"""Handle '?' key - push help screen"""
|
|
@@ -807,84 +1341,289 @@ class HelpScreen(DiskWipeScreen):
|
|
|
807
1341
|
spinner.show_help_nav_keys(app.win)
|
|
808
1342
|
spinner.show_help_body(app.win)
|
|
809
1343
|
|
|
1344
|
+
# Add CLI Options section
|
|
1345
|
+
app.win.add_body('Command Line Arguments:', attr=cs.A_UNDERLINE)
|
|
1346
|
+
opts, wid = app.opts, 8
|
|
1347
|
+
cli_options = [
|
|
1348
|
+
f'--mode: . . . . {opts.mode:<{wid}} Wipe mode',
|
|
1349
|
+
f'--passes: . . . {opts.passes:<{wid}} Passes for software wipes',
|
|
1350
|
+
f'--verify-pct: . {opts.verify_pct:<{wid}} Verification %',
|
|
1351
|
+
f'--port-serial: {opts.port_serial:<{wid}} Show port/serial/FwCAPS',
|
|
1352
|
+
f'--slowdown-stop: {opts.slowdown_stop:<{wid}} Stop if disk slows',
|
|
1353
|
+
f'--stall-timeout: {opts.stall_timeout:<{wid}} Stall timeout in sec',
|
|
1354
|
+
]
|
|
1355
|
+
for opt in cli_options:
|
|
1356
|
+
app.win.add_body(opt, attr=cs.A_DIM)
|
|
1357
|
+
|
|
810
1358
|
|
|
811
1359
|
|
|
812
1360
|
class HistoryScreen(DiskWipeScreen):
|
|
813
|
-
"""History/log screen showing
|
|
1361
|
+
"""History/log screen showing structured log entries with expand/collapse functionality"""
|
|
1362
|
+
|
|
1363
|
+
refresh_seconds = 60.0 # Slower refresh for history screen to allow copy/paste
|
|
1364
|
+
|
|
1365
|
+
def __init__(self, app):
|
|
1366
|
+
super().__init__(app)
|
|
1367
|
+
self.expands = {} # Maps timestamp -> True (expanded) or False (collapsed)
|
|
1368
|
+
self.entries = [] # Cached log entries (all entries before filtering)
|
|
1369
|
+
self.filtered_entries = [] # Entries after search filtering
|
|
1370
|
+
self.window_of_logs = None # Window of log entries (OrderedDict)
|
|
1371
|
+
self.window_state = None # Window state for incremental reads
|
|
1372
|
+
self.search_matches = set() # Set of timestamps with deep-only matches in JSON
|
|
1373
|
+
self.prev_filter = ''
|
|
1374
|
+
|
|
1375
|
+
# Setup search bar
|
|
1376
|
+
self.search_bar = IncrementalSearchBar(
|
|
1377
|
+
on_change=self._on_search_change,
|
|
1378
|
+
on_accept=self._on_search_accept,
|
|
1379
|
+
on_cancel=self._on_search_cancel
|
|
1380
|
+
)
|
|
1381
|
+
|
|
1382
|
+
def _on_search_change(self, text):
|
|
1383
|
+
"""Called when search text changes - filter entries incrementally."""
|
|
1384
|
+
self._filter_entries(text)
|
|
1385
|
+
|
|
1386
|
+
def _on_search_accept(self, text):
|
|
1387
|
+
"""Called when ENTER pressed in search - keep filter active, exit input mode."""
|
|
1388
|
+
self.app.win.passthrough_mode = False
|
|
1389
|
+
self.prev_filter = text
|
|
1390
|
+
|
|
1391
|
+
def _on_search_cancel(self, original_text):
|
|
1392
|
+
"""Called when ESC pressed in search - restore and exit search mode."""
|
|
1393
|
+
self._filter_entries(original_text)
|
|
1394
|
+
self.app.win.passthrough_mode = False
|
|
1395
|
+
|
|
1396
|
+
def _filter_entries(self, search_text):
|
|
1397
|
+
"""Filter entries based on search text (shallow or deep)."""
|
|
1398
|
+
if not search_text:
|
|
1399
|
+
self.filtered_entries = self.entries
|
|
1400
|
+
self.search_matches = set()
|
|
1401
|
+
return
|
|
1402
|
+
|
|
1403
|
+
# Deep search mode if starts with /
|
|
1404
|
+
deep_search = search_text.startswith('/')
|
|
1405
|
+
pattern = search_text[1:] if deep_search else search_text
|
|
1406
|
+
|
|
1407
|
+
if not pattern:
|
|
1408
|
+
self.filtered_entries = self.entries
|
|
1409
|
+
self.search_matches = set()
|
|
1410
|
+
return
|
|
1411
|
+
|
|
1412
|
+
# Use StructuredLogger's filter method
|
|
1413
|
+
# logger = Utils.get_logger()
|
|
1414
|
+
self.filtered_entries, self.search_matches = StructuredLogger.filter_entries(
|
|
1415
|
+
self.entries, pattern, deep=deep_search
|
|
1416
|
+
)
|
|
814
1417
|
|
|
815
1418
|
def draw_screen(self):
|
|
816
|
-
"""Draw the history screen"""
|
|
817
|
-
app = self.app
|
|
818
|
-
# spinner = self.get_spinner()
|
|
1419
|
+
"""Draw the history screen with structured log entries"""
|
|
819
1420
|
|
|
820
|
-
|
|
1421
|
+
def format_ago(timestamp):
|
|
1422
|
+
nonlocal now_dt
|
|
1423
|
+
ts = datetime.fromisoformat(timestamp)
|
|
1424
|
+
delta = now_dt - ts
|
|
1425
|
+
return Utils.ago_str(int(round(delta.total_seconds())))
|
|
821
1426
|
|
|
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)
|
|
825
1427
|
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
1428
|
+
now_dt = datetime.now()
|
|
1429
|
+
app = self.app
|
|
1430
|
+
win = app.win
|
|
1431
|
+
win.set_pick_mode(True)
|
|
1432
|
+
|
|
1433
|
+
# Get window of log entries (chronological order - eldest to youngest)
|
|
1434
|
+
logger = Utils.get_logger()
|
|
1435
|
+
if self.window_of_logs is None:
|
|
1436
|
+
self.window_of_logs, self.window_state = logger.get_window_of_entries(window_size=1000)
|
|
1437
|
+
else:
|
|
1438
|
+
# Refresh window with any new entries
|
|
1439
|
+
self.window_of_logs, self.window_state = logger.refresh_window(
|
|
1440
|
+
self.window_of_logs, self.window_state, window_size=1000
|
|
1441
|
+
)
|
|
1442
|
+
|
|
1443
|
+
# Convert to list in reverse order (newest first for display)
|
|
1444
|
+
self.entries = list(reversed(list(self.window_of_logs.values())))
|
|
1445
|
+
|
|
1446
|
+
# Clean up self.expands: remove any timestamps that are no longer in entries
|
|
1447
|
+
valid_timestamps = {entry.timestamp for entry in self.entries}
|
|
1448
|
+
self.expands = {ts: state for ts, state in self.expands.items() if ts in valid_timestamps}
|
|
1449
|
+
|
|
1450
|
+
# Apply search filter if active
|
|
1451
|
+
if not self.search_bar.text:
|
|
1452
|
+
self.filtered_entries = self.entries
|
|
1453
|
+
self.search_matches = set()
|
|
1454
|
+
|
|
1455
|
+
# Count by level in filtered results
|
|
1456
|
+
level_counts = {}
|
|
1457
|
+
for e in self.filtered_entries:
|
|
1458
|
+
level_counts[e.level] = level_counts.get(e.level, 0) + 1
|
|
1459
|
+
|
|
1460
|
+
# Build search display string
|
|
1461
|
+
search_display = self.search_bar.get_display_string(prefix='', suffix='')
|
|
1462
|
+
|
|
1463
|
+
# Build level summary for header
|
|
1464
|
+
# level_summary = ' '.join(f'{lvl}:{cnt}' for lvl, cnt in sorted(level_counts.items()))
|
|
1465
|
+
|
|
1466
|
+
# Header
|
|
1467
|
+
# header_line = f'ESC:back [e]xpand [/]search {len(self.filtered_entries)}/{len(self.entries)} ({level_summary}) '
|
|
1468
|
+
header_line = f'ESC:back [e]xpand [c]opy [/]search {len(self.filtered_entries)}/{len(self.entries)} '
|
|
1469
|
+
if search_display:
|
|
1470
|
+
header_line += f'/ {search_display}'
|
|
838
1471
|
else:
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
1472
|
+
header_line += '/'
|
|
1473
|
+
win.add_header(header_line)
|
|
1474
|
+
win.add_header(f'Log: {logger.log_file}')
|
|
1475
|
+
|
|
1476
|
+
# Build display
|
|
1477
|
+
for entry in self.filtered_entries:
|
|
1478
|
+
timestamp = entry.timestamp
|
|
1479
|
+
|
|
1480
|
+
# Get display summary from entry
|
|
1481
|
+
summary = entry.display_summary
|
|
1482
|
+
|
|
1483
|
+
# Format timestamp based on spinner setting
|
|
1484
|
+
time_format = app.opts.hist_time_format
|
|
1485
|
+
|
|
1486
|
+
if time_format == 'ago':
|
|
1487
|
+
timestamp_display = f"{format_ago(timestamp):>6}"
|
|
1488
|
+
elif time_format == 'ago+time':
|
|
1489
|
+
ago = format_ago(timestamp)
|
|
1490
|
+
time_str = timestamp[:19]
|
|
1491
|
+
timestamp_display = f"{ago:>6} {time_str}"
|
|
1492
|
+
else: # 'time'
|
|
1493
|
+
timestamp_display = timestamp[:19] # Just the date and time part (YYYY-MM-DD HH:MM:SS)
|
|
1494
|
+
|
|
1495
|
+
level = entry.level
|
|
1496
|
+
|
|
1497
|
+
# Add deep match indicator if this entry matched only in JSON
|
|
1498
|
+
deep_indicator = " *" if timestamp in self.search_matches else ""
|
|
1499
|
+
|
|
1500
|
+
# Choose color based on log level
|
|
1501
|
+
if level == 'ERR':
|
|
1502
|
+
level_attr = cs.color_pair(Theme.ERROR) | cs.A_BOLD
|
|
1503
|
+
elif level in ('WIPE_STOPPED', 'VERIFY_STOPPED'):
|
|
1504
|
+
level_attr = cs.color_pair(Theme.WARNING) | cs.A_BOLD
|
|
1505
|
+
elif level in ('WIPE_COMPLETE', 'VERIFY_COMPLETE'):
|
|
1506
|
+
level_attr = cs.color_pair(Theme.SUCCESS) | cs.A_BOLD
|
|
1507
|
+
else:
|
|
1508
|
+
level_attr = cs.A_BOLD
|
|
1509
|
+
|
|
1510
|
+
line = f"{timestamp_display} {summary}{deep_indicator}"
|
|
1511
|
+
win.add_body(line, attr=level_attr, context=Context("header", timestamp=timestamp))
|
|
1512
|
+
|
|
1513
|
+
# Handle expansion - show the structured data
|
|
1514
|
+
if self.expands.get(timestamp, False):
|
|
1515
|
+
# Show the full entry data as formatted JSON
|
|
1516
|
+
try:
|
|
1517
|
+
data_dict = entry.to_dict()
|
|
1518
|
+
# Format just the 'data' field if it exists, otherwise show all
|
|
1519
|
+
if 'data' in data_dict and data_dict['data']:
|
|
1520
|
+
formatted = json.dumps(data_dict['data'], indent=2)
|
|
1521
|
+
else:
|
|
1522
|
+
formatted = json.dumps(data_dict, indent=2)
|
|
842
1523
|
|
|
1524
|
+
lines = formatted.split('\n')
|
|
1525
|
+
for line in lines:
|
|
1526
|
+
win.add_body(f" {line}", context=Context("body", timestamp=timestamp))
|
|
843
1527
|
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
prev_theme = ""
|
|
1528
|
+
except Exception as e:
|
|
1529
|
+
win.add_body(f" (error formatting: {e})", attr=cs.A_DIM)
|
|
847
1530
|
|
|
848
|
-
|
|
849
|
-
|
|
1531
|
+
# Empty line between entries
|
|
1532
|
+
win.add_body("", context=Context("DECOR"))
|
|
1533
|
+
|
|
1534
|
+
def expand_ACTION(self):
|
|
1535
|
+
"""'e' key - Expand/collapse current entry"""
|
|
850
1536
|
app = self.app
|
|
1537
|
+
win = app.win
|
|
1538
|
+
ctx = win.get_picked_context()
|
|
1539
|
+
|
|
1540
|
+
if ctx and hasattr(ctx, 'timestamp'):
|
|
1541
|
+
timestamp = ctx.timestamp
|
|
1542
|
+
# Toggle between collapsed and expanded
|
|
1543
|
+
current = self.expands.get(timestamp, False)
|
|
1544
|
+
if current: # Collapsing
|
|
1545
|
+
del self.expands[timestamp]
|
|
1546
|
+
# Search backwards to find first context with matching timestamp
|
|
1547
|
+
# This should be the header line of this entry
|
|
1548
|
+
test_pos = win.pick_pos - 1
|
|
1549
|
+
while test_pos >= 0:
|
|
1550
|
+
test_ctx = win.body.contexts[test_pos]
|
|
1551
|
+
if test_ctx and hasattr(test_ctx, 'timestamp') and test_ctx.timestamp == timestamp:
|
|
1552
|
+
win.pick_pos = test_pos
|
|
1553
|
+
test_pos -= 1
|
|
1554
|
+
else:
|
|
1555
|
+
return
|
|
1556
|
+
else:
|
|
1557
|
+
# Expanding - just toggle, cursor stays where it is
|
|
1558
|
+
self.expands[timestamp] = True
|
|
851
1559
|
|
|
852
|
-
|
|
1560
|
+
def filter_ACTION(self):
|
|
1561
|
+
"""'/' key - Start incremental search"""
|
|
1562
|
+
app = self.app
|
|
1563
|
+
self.search_bar.start(self.prev_filter)
|
|
1564
|
+
app.win.passthrough_mode = True
|
|
853
1565
|
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
app
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
1566
|
+
def copy_ACTION(self):
|
|
1567
|
+
"""'c' key - Copy current entry to clipboard or print to terminal"""
|
|
1568
|
+
from .Utils import ClipboardHelper
|
|
1569
|
+
app = self.app
|
|
1570
|
+
win = app.win
|
|
1571
|
+
ctx = win.get_picked_context()
|
|
1572
|
+
|
|
1573
|
+
if not ctx or not hasattr(ctx, 'timestamp'):
|
|
1574
|
+
return
|
|
1575
|
+
|
|
1576
|
+
# Find the entry by timestamp
|
|
1577
|
+
timestamp = ctx.timestamp
|
|
1578
|
+
entry = None
|
|
1579
|
+
for e in self.entries:
|
|
1580
|
+
if e.timestamp == timestamp:
|
|
1581
|
+
entry = e
|
|
1582
|
+
break
|
|
1583
|
+
|
|
1584
|
+
if not entry:
|
|
1585
|
+
return
|
|
1586
|
+
|
|
1587
|
+
# Format entry as JSON
|
|
1588
|
+
entry_text = json.dumps(entry.to_dict(), indent=2)
|
|
1589
|
+
|
|
1590
|
+
# Try clipboard first
|
|
1591
|
+
if ClipboardHelper.has_clipboard():
|
|
1592
|
+
success, error = ClipboardHelper.copy(entry_text)
|
|
1593
|
+
if success:
|
|
1594
|
+
# Show brief success indicator - use the header context temporarily
|
|
1595
|
+
# The message will be visible until next refresh
|
|
1596
|
+
win.add_header(f'Copied to clipboard ({ClipboardHelper.get_method_name()})')
|
|
1597
|
+
else:
|
|
1598
|
+
win.add_header(f'Clipboard error: {error}')
|
|
1599
|
+
else:
|
|
1600
|
+
# Terminal fallback: exit curses, print, wait for input
|
|
1601
|
+
self._copy_terminal_fallback(entry_text)
|
|
1602
|
+
|
|
1603
|
+
def _copy_terminal_fallback(self, text):
|
|
1604
|
+
"""Print entry to terminal when clipboard unavailable (e.g., SSH sessions)."""
|
|
1605
|
+
from .Utils import ClipboardHelper
|
|
1606
|
+
app = self.app
|
|
1607
|
+
|
|
1608
|
+
# Exit curses to use the terminal
|
|
1609
|
+
ConsoleWindow.stop_curses()
|
|
1610
|
+
os.system('clear; stty sane')
|
|
1611
|
+
|
|
1612
|
+
# Print with border
|
|
1613
|
+
print('=' * 60)
|
|
1614
|
+
print('LOG ENTRY (copy manually from terminal):')
|
|
1615
|
+
print('=' * 60)
|
|
1616
|
+
print(text)
|
|
1617
|
+
print('=' * 60)
|
|
1618
|
+
print(f'\nClipboard: {ClipboardHelper.get_method_name()}')
|
|
1619
|
+
print('\nPress ENTER to return to dwipe...')
|
|
1620
|
+
|
|
1621
|
+
# Wait for user input
|
|
1622
|
+
try:
|
|
1623
|
+
input()
|
|
1624
|
+
except EOFError:
|
|
1625
|
+
pass
|
|
871
1626
|
|
|
872
|
-
#
|
|
873
|
-
|
|
874
|
-
|
|
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
|
|
1627
|
+
# Restore curses
|
|
1628
|
+
ConsoleWindow.start_curses()
|
|
1629
|
+
app.win.pick_pos = app.win.pick_pos # Force position refresh
|