Skip to content

Commit 42e19c9

Browse files
Fixes to ZFS command logic.
First prompt: Call the ZFS programs only when application start and call again only if the user presses F5. For `zpool iostat` call it once and let the program run in a separated thread continuosly grabbing its output. For better output use the `-H` flag to omit the header and make the parser easier as it will separate columns using tabs (\t). Second prompt: The screen is not being updated with data from zpool iostat, the vdevs table and IOPS should be update with this data.
1 parent 4b97bed commit 42e19c9

File tree

4 files changed

+180
-52
lines changed

4 files changed

+180
-52
lines changed

src/zfs_dashboard/ui/app.py

Lines changed: 73 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,62 @@
11
from textual.app import App
22
from textual.binding import Binding
33

4-
from ..zfs import get_system_status
4+
from ..zfs import get_static_data, parse_iostat_line
55
from .screens import DashboardScreen
6+
import threading
7+
import subprocess
8+
import time
9+
10+
class IostatWorker(threading.Thread):
11+
def __init__(self, callback):
12+
super().__init__(daemon=True)
13+
self.callback = callback
14+
self.running = True
15+
16+
def run(self):
17+
# Run zpool iostat -H -p -y 1
18+
# -H: Scripted mode (no headers, tabs)
19+
# -p: Parsable numbers
20+
# -y: Omit first report (since boot) - available in newer ZFS
21+
# If -y not supported, we just ignore the first line if it looks huge?
22+
# Actually -y is best. If not available, we might see a big spike at start.
23+
# Let's assume -y is available or acceptable behavior.
24+
# We need to run it continuously.
25+
26+
cmd = ['zpool', 'iostat', '-v', '-H', '-p', '-y', '1']
27+
28+
# Fallback if -y is not supported?
29+
# Let's try running it.
30+
try:
31+
process = subprocess.Popen(
32+
cmd,
33+
stdout=subprocess.PIPE,
34+
stderr=subprocess.PIPE,
35+
text=True,
36+
bufsize=1 # Line buffered
37+
)
38+
39+
while self.running and process.poll() is None:
40+
line = process.stdout.readline()
41+
if not line:
42+
break
43+
44+
stats = parse_iostat_line(line)
45+
if stats:
46+
self.callback(stats)
47+
48+
except FileNotFoundError:
49+
pass # zpool not found
50+
except Exception as e:
51+
print(f"Iostat worker error: {e}")
52+
finally:
53+
if process:
54+
process.terminate()
55+
process.wait()
56+
57+
def stop(self):
58+
self.running = False
59+
660

761
class ZfsDashboardApp(App):
862
CSS = """
@@ -52,10 +106,26 @@ def __init__(self, interval: int = 5, pool_filter: str = None, dataset_filter: s
52106

53107
def on_mount(self):
54108
self.action_refresh_data()
55-
self.set_interval(self.interval, self.action_refresh_data)
109+
# Start background worker
110+
self.worker = IostatWorker(self.update_iostat)
111+
self.worker.start()
112+
113+
def on_unmount(self):
114+
if hasattr(self, 'worker'):
115+
self.worker.stop()
116+
117+
def update_iostat(self, stats):
118+
# stats is tuple: (name, read_ops, write_ops, read_bytes, write_bytes)
119+
# We need to update the UI.
120+
# Since this is called from a thread, we must use call_from_thread
121+
self.call_from_thread(self._update_iostat_ui, stats)
122+
123+
def _update_iostat_ui(self, stats):
124+
if self.screen and isinstance(self.screen, DashboardScreen):
125+
self.screen.update_iostat_data(stats)
56126

57127
def action_refresh_data(self):
58-
all_pools = get_system_status()
128+
all_pools = get_static_data()
59129

60130
# Apply pool filter
61131
if self.pool_filter:

src/zfs_dashboard/ui/screens.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,3 +143,52 @@ def on_dataset_tree_widget_selected(self, message: DatasetTreeWidget.Selected):
143143
pool_name = tree_id.split("-", 1)[1]
144144
details = self.query_one(f"#details-{pool_name}", DatasetDetails)
145145
details.dataset = message.dataset
146+
147+
def update_iostat_data(self, stats):
148+
# stats: (name, read_ops, write_ops, read_bytes, write_bytes)
149+
name, r_ops, w_ops, r_bytes, w_bytes = stats
150+
151+
# We need to find the pool or vdev with this name.
152+
# Since we don't know if it's a pool or vdev easily without context,
153+
# we can try to find widgets that match.
154+
# Pools have IDs like `overview-{pool_name}`
155+
# Vdevs are inside `VdevList`, which we need to access.
156+
157+
# 1. Check if it's a pool
158+
# We can iterate over our pools to see if name matches
159+
for pool in self.pools:
160+
if pool.name == name:
161+
# Update pool stats
162+
pool.read_ops = r_ops
163+
pool.write_ops = w_ops
164+
pool.read_bytes = r_bytes
165+
pool.write_bytes = w_bytes
166+
167+
# Update UI
168+
try:
169+
overview = self.query_one(f"#overview-{pool.name}", PoolOverview)
170+
overview.update_stats()
171+
except Exception:
172+
pass
173+
174+
# Also update "All" tab if it exists
175+
if len(self.pools) > 1:
176+
self.update_all_tab()
177+
return
178+
179+
# 2. Check if it's a vdev in this pool
180+
for vdev in pool.vdevs:
181+
if vdev.name == name:
182+
vdev.read_ops = r_ops
183+
vdev.write_ops = w_ops
184+
vdev.read_bytes = r_bytes
185+
vdev.write_bytes = w_bytes
186+
187+
# Update VdevList
188+
try:
189+
vdev_list = self.query_one(f"#vdevs-{pool.name}", VdevList)
190+
vdev_list.update_vdevs()
191+
except Exception:
192+
pass
193+
return
194+

src/zfs_dashboard/ui/widgets.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,11 @@ def compose(self) -> ComposeResult:
2121
yield Label("Write IOPS", classes="header-small")
2222
yield Sparkline(data=[], summary_function=max, id="write-ops-spark")
2323

24+
def update_stats(self):
25+
"""Force update of stats from self.pool"""
26+
if self.pool:
27+
self.watch_pool(self.pool)
28+
2429
def watch_pool(self, pool: Pool):
2530
if pool:
2631
self.query_one("#pool-state", Label).update(f"State: {pool.state} | Health: {pool.health}")
@@ -62,6 +67,10 @@ def on_mount(self):
6267
table = self.query_one(DataTable)
6368
table.add_columns("Name", "State", "Read", "Write", "Cksum", "Type", "R IOPS", "W IOPS", "R Bytes", "W Bytes")
6469

70+
def update_vdevs(self):
71+
"""Force update of vdev table"""
72+
self.watch_vdevs(self.vdevs)
73+
6574
def watch_vdevs(self, vdevs: list[Vdev]):
6675
table = self.query_one(DataTable)
6776
table.clear()

src/zfs_dashboard/zfs.py

Lines changed: 49 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,46 @@ def build_dataset_tree(datasets: List[Dataset]) -> List[Dataset]:
182182

183183
return roots
184184

185+
def parse_iostat_line(line: str) -> Optional[tuple[str, str, int, int, int, int]]:
186+
"""
187+
Parses a single line from `zpool iostat -H -p -y 1`.
188+
Format: pool_name alloc free read_ops write_ops read_bytes write_bytes
189+
Or: vdev_name alloc free read_ops write_ops read_bytes write_bytes
190+
191+
Returns: (name, type_hint, read_ops, write_ops, read_bytes, write_bytes)
192+
type_hint is 'pool' or 'vdev' based on context (caller needs to track).
193+
Actually, `zpool iostat -H` prints flat lines.
194+
We need to know the structure.
195+
But usually it prints:
196+
pool_name ...
197+
vdev ...
198+
199+
With -H, indentation is preserved?
200+
Let's check `zpool iostat -H` behavior.
201+
Usually -H removes headers and separates with tabs.
202+
It DOES NOT preserve indentation usually.
203+
Wait, if indentation is lost, we can't distinguish pool from vdev easily if names are similar.
204+
However, the order is always Pool -> Vdevs.
205+
206+
Let's assume the caller handles the hierarchy or we just return the raw stats and name.
207+
"""
208+
parts = line.strip().split('\t')
209+
if len(parts) < 7:
210+
return None
211+
212+
name = parts[0]
213+
try:
214+
# parts[1] alloc, parts[2] free
215+
read_ops = int(parts[3])
216+
write_ops = int(parts[4])
217+
read_bytes = int(parts[5])
218+
write_bytes = int(parts[6])
219+
return (name, read_ops, write_ops, read_bytes, write_bytes)
220+
except (ValueError, IndexError):
221+
return None
222+
185223
def parse_zpool_iostat(output: str) -> Dict[str, Dict[str, dict]]:
224+
186225
"""
187226
Parses `zpool iostat -v -p` (using -p for exact numbers)
188227
Returns nested dict: pool -> vdev -> {read_ops, write_ops, read_bytes, write_bytes}
@@ -263,7 +302,11 @@ def parse_zpool_iostat(output: str) -> Dict[str, Dict[str, dict]]:
263302

264303
return stats
265304

266-
def get_system_status() -> List[Pool]:
305+
def get_static_data() -> List[Pool]:
306+
"""
307+
Fetches static structure: Pools, Vdevs, Datasets.
308+
Does NOT fetch realtime IO stats.
309+
"""
267310
# 1. Get Pools
268311
pool_list_out = run_command(['zpool', 'list', '-H', '-o', 'name,size,alloc,free,frag,cap,health,altroot'])
269312
pools = parse_zpool_list(pool_list_out)
@@ -280,38 +323,6 @@ def get_system_status() -> List[Pool]:
280323
snap_out = run_command(['zfs', 'list', '-H', '-t', 'snapshot', '-o', 'name,used'])
281324
snaps_map = parse_zfs_snapshots(snap_out)
282325

283-
# 5. Get IO Stats
284-
# Use -p for parsable numbers, -v for verbose (vdevs)
285-
# Note: `zpool iostat` without interval prints stats since boot.
286-
# For realtime, we usually want current activity.
287-
# `zpool iostat -v -p 1 2` prints 2 samples, first is since boot, second is last 1 sec.
288-
# But that blocks for 1 second.
289-
# If we want non-blocking, we have to accept "since boot" or manage a background process.
290-
# Requirement: "Realtime Monitoring ... updated every 5 seconds".
291-
# If we run `zpool iostat` every 5 seconds, we get "since boot" stats if we don't specify interval.
292-
# If we specify interval, it blocks.
293-
# Textual app is async, we could run it in a worker?
294-
# Or we can just show "since boot" averages?
295-
# Usually "Realtime" implies current load.
296-
# Let's try to run `zpool iostat -v -p 1 1` which waits 1 second and gives 1 sample (actually 2 lines of headers + 2 samples).
297-
# The second sample is the current load.
298-
# But this adds 1s latency to the refresh.
299-
# Let's use `zpool iostat -v -p -y 1 1`? -y omits first report (since boot) on recent ZFS versions.
300-
# If -y is not supported, we parse the second data block.
301-
302-
# Let's try `zpool iostat -v -p 1 1` and parse the last set of values.
303-
# This will block for 1 second.
304-
iostat_out = run_command(['zpool', 'iostat', '-v', '-p', '1', '1'])
305-
# This command outputs:
306-
# Header
307-
# Stats since boot
308-
# Header (sometimes)
309-
# Stats for 1s
310-
311-
# We need to parse this carefully.
312-
# If we just take the last occurrence of each pool/vdev, it should be the latest sample.
313-
io_stats = parse_zpool_iostat(iostat_out)
314-
315326
# Attach snapshots to datasets
316327
for ds in all_datasets:
317328
if ds.name in snaps_map:
@@ -325,23 +336,12 @@ def get_system_status() -> List[Pool]:
325336
if pool.name in vdevs_map:
326337
pool.vdevs = vdevs_map[pool.name]
327338

328-
# Attach IO Stats
329-
if pool.name in io_stats:
330-
p_stats = io_stats[pool.name].get("__pool__", {})
331-
pool.read_ops = p_stats.get("read_ops", 0)
332-
pool.write_ops = p_stats.get("write_ops", 0)
333-
pool.read_bytes = p_stats.get("read_bytes", 0)
334-
pool.write_bytes = p_stats.get("write_bytes", 0)
335-
336-
for vdev in pool.vdevs:
337-
if vdev.name in io_stats[pool.name]:
338-
v_stats = io_stats[pool.name][vdev.name]
339-
vdev.read_ops = v_stats.get("read_ops", 0)
340-
vdev.write_ops = v_stats.get("write_ops", 0)
341-
vdev.read_bytes = v_stats.get("read_bytes", 0)
342-
vdev.write_bytes = v_stats.get("write_bytes", 0)
343-
344339
# Find root dataset for this pool
345340
pool.datasets = [ds for ds in root_datasets if ds.name == pool.name]
346341

347342
return pools
343+
344+
def get_system_status() -> List[Pool]:
345+
# Deprecated: Use get_static_data for structure and background thread for stats
346+
return get_static_data()
347+

0 commit comments

Comments
 (0)