Skip to main content

max / mountaineer

sysop storage: pool detail (vdev tree) + dataset list `Enter` on a pool opens a detail view rendering `zpool status -P <pool>`: scan/scrub line, errors line, and the indented vdev tree with per-vdev state and read/write/cksum error counters. `d` from the pool list opens a dataset view backed by `zfs list -H -o name,used,avail,mountpoint`, sorted in zfs's natural hierarchical order. Mock backend ships a degraded `tank` with a faulted child plus an in-progress scrub, so the detail layout is exercised on host dev.
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-05-19 21:12 UTC
Commit: 533af2676b0aeb8e92a7b678b12e790fbf4f3094
Parent: 1f262c6
1 file changed, +361 insertions, -21 deletions
@@ -1,5 +1,7 @@
1 1 use anyhow::{Context, Result};
2 2 use crossterm::event::{self, Event, KeyCode};
3 + use ratatui::Frame;
4 + use ratatui::layout::Rect;
3 5 use ratatui::style::Style;
4 6 use ratatui::text::{Line, Span};
5 7 use ratatui::widgets::Paragraph;
@@ -63,8 +65,35 @@ pub struct Pool {
63 65 pub capacity_pct: String,
64 66 }
65 67
68 + #[derive(Debug, Clone)]
69 + pub struct VdevEntry {
70 + pub indent: usize,
71 + pub name: String,
72 + pub state: State,
73 + pub read_errors: u64,
74 + pub write_errors: u64,
75 + pub cksum_errors: u64,
76 + }
77 +
78 + #[derive(Debug, Clone)]
79 + pub struct PoolStatus {
80 + pub scan: Option<String>,
81 + pub vdevs: Vec<VdevEntry>,
82 + pub errors: Option<String>,
83 + }
84 +
85 + #[derive(Debug, Clone)]
86 + pub struct Dataset {
87 + pub name: String,
88 + pub used: String,
89 + pub avail: String,
90 + pub mountpoint: String,
91 + }
92 +
66 93 pub trait Backend {
67 94 fn list(&self) -> Result<Vec<Pool>>;
95 + fn pool_status(&self, name: &str) -> Result<PoolStatus>;
96 + fn datasets(&self) -> Result<Vec<Dataset>>;
68 97 }
69 98
70 99 fn detect() -> Box<dyn Backend> {
@@ -116,6 +145,99 @@ impl Backend for Zfs {
116 145 }
117 146 Ok(pools)
118 147 }
148 +
149 + fn pool_status(&self, name: &str) -> Result<PoolStatus> {
150 + let out = Command::new("zpool")
151 + .args(["status", "-P", name])
152 + .output()
153 + .context("zpool status")?;
154 + if !out.status.success() {
155 + anyhow::bail!("zpool status failed");
156 + }
157 + Ok(parse_status(&String::from_utf8_lossy(&out.stdout)))
158 + }
159 +
160 + fn datasets(&self) -> Result<Vec<Dataset>> {
161 + let out = Command::new("zfs")
162 + .args(["list", "-H", "-o", "name,used,avail,mountpoint"])
163 + .output()
164 + .context("zfs list")?;
165 + if !out.status.success() {
166 + anyhow::bail!("zfs list failed");
167 + }
168 + let mut datasets = Vec::new();
169 + for line in String::from_utf8_lossy(&out.stdout).lines() {
170 + let cols: Vec<&str> = line.split('\t').collect();
171 + if cols.len() < 4 {
172 + continue;
173 + }
174 + datasets.push(Dataset {
175 + name: cols[0].to_string(),
176 + used: cols[1].to_string(),
177 + avail: cols[2].to_string(),
178 + mountpoint: cols[3].to_string(),
179 + });
180 + }
181 + Ok(datasets)
182 + }
183 + }
184 +
185 + fn parse_status(text: &str) -> PoolStatus {
186 + let mut scan = None;
187 + let mut errors = None;
188 + let mut vdevs = Vec::new();
189 + let mut in_config = false;
190 + let mut header_seen = false;
191 +
192 + for raw in text.lines() {
193 + let trimmed = raw.trim_start();
194 + if let Some(rest) = trimmed.strip_prefix("scan:") {
195 + scan = Some(rest.trim().to_string());
196 + continue;
197 + }
198 + if let Some(rest) = trimmed.strip_prefix("errors:") {
199 + errors = Some(rest.trim().to_string());
200 + in_config = false;
201 + continue;
202 + }
203 + if trimmed.starts_with("config:") {
204 + in_config = true;
205 + header_seen = false;
206 + continue;
207 + }
208 + if !in_config {
209 + continue;
210 + }
211 + if raw.trim().is_empty() {
212 + continue;
213 + }
214 + // Skip the "NAME STATE READ WRITE CKSUM" header
215 + if !header_seen && trimmed.starts_with("NAME") {
216 + header_seen = true;
217 + continue;
218 + }
219 + if let Some(entry) = parse_vdev_line(raw) {
220 + vdevs.push(entry);
221 + }
222 + }
223 +
224 + PoolStatus { scan, vdevs, errors }
225 + }
226 +
227 + fn parse_vdev_line(raw: &str) -> Option<VdevEntry> {
228 + let indent = raw.chars().take_while(|c| *c == ' ' || *c == '\t').count();
229 + let parts: Vec<&str> = raw.split_whitespace().collect();
230 + if parts.len() < 5 {
231 + return None;
232 + }
233 + Some(VdevEntry {
234 + indent,
235 + name: parts[0].to_string(),
236 + state: State::parse(parts[1]),
237 + read_errors: parts[2].parse().unwrap_or(0),
238 + write_errors: parts[3].parse().unwrap_or(0),
239 + cksum_errors: parts[4].parse().unwrap_or(0),
240 + })
119 241 }
120 242
121 243 struct Mock;
@@ -139,10 +261,64 @@ impl Backend for Mock {
139 261 },
140 262 ])
141 263 }
264 +
265 + fn pool_status(&self, name: &str) -> Result<PoolStatus> {
266 + let status = match name {
267 + "rpool" => PoolStatus {
268 + scan: Some("scrub repaired 0B in 00:08:14 with 0 errors on Sun May 17 02:30:01 2026".into()),
269 + vdevs: vec![
270 + VdevEntry { indent: 0, name: "rpool".into(), state: State::Online, read_errors: 0, write_errors: 0, cksum_errors: 0 },
271 + VdevEntry { indent: 2, name: "/dev/sda3".into(), state: State::Online, read_errors: 0, write_errors: 0, cksum_errors: 0 },
272 + ],
273 + errors: Some("No known data errors".into()),
274 + },
275 + "tank" => PoolStatus {
276 + scan: Some("scrub in progress since Mon May 19 06:00:01 2026, 42.1% done, 0h11m to go".into()),
277 + vdevs: vec![
278 + VdevEntry { indent: 0, name: "tank".into(), state: State::Degraded, read_errors: 0, write_errors: 0, cksum_errors: 0 },
279 + VdevEntry { indent: 2, name: "mirror-0".into(), state: State::Degraded, read_errors: 0, write_errors: 0, cksum_errors: 0 },
280 + VdevEntry { indent: 4, name: "/dev/sdb".into(), state: State::Online, read_errors: 0, write_errors: 0, cksum_errors: 0 },
281 + VdevEntry { indent: 4, name: "/dev/sdc".into(), state: State::Faulted, read_errors: 3, write_errors: 0, cksum_errors: 1 },
282 + ],
283 + errors: Some("No known data errors".into()),
284 + },
285 + _ => PoolStatus { scan: None, vdevs: Vec::new(), errors: None },
286 + };
287 + Ok(status)
288 + }
289 +
290 + fn datasets(&self) -> Result<Vec<Dataset>> {
291 + Ok(vec![
292 + ds("rpool", "62.1G", "351G", "/rpool"),
293 + ds("rpool/ROOT", "8.4G", "351G", "none"),
294 + ds("rpool/ROOT/mountaineer", "8.4G", "351G", "/"),
295 + ds("rpool/home", "1.2G", "351G", "/home"),
296 + ds("rpool/var", "2.6G", "351G", "/var"),
297 + ds("tank", "1.20T", "612G", "/tank"),
298 + ds("tank/media", "980G", "612G", "/tank/media"),
299 + ds("tank/backups", "220G", "612G", "/tank/backups"),
300 + ])
301 + }
302 + }
303 +
304 + fn ds(name: &str, used: &str, avail: &str, mountpoint: &str) -> Dataset {
305 + Dataset {
306 + name: name.into(),
307 + used: used.into(),
308 + avail: avail.into(),
309 + mountpoint: mountpoint.into(),
310 + }
311 + }
312 +
313 + enum View {
314 + Pools { selected: usize },
315 + Detail { pool: String, status: PoolStatus },
316 + Datasets { selected: usize, datasets: Vec<Dataset> },
142 317 }
143 318
144 319 pub fn run(summary: bool) -> Result<i32> {
145 - let pools = detect().list()?;
320 + let backend = detect();
321 + let pools = backend.list()?;
146 322
147 323 if summary {
148 324 let healthy = pools.iter().all(|p| p.state.is_healthy());
@@ -151,41 +327,108 @@ pub fn run(summary: bool) -> Result<i32> {
151 327 }
152 328
153 329 let mut terminal = ratatui::init();
154 - let result = event_loop(&mut terminal, &pools);
330 + let result = event_loop(&mut terminal, backend.as_ref(), pools);
155 331 ratatui::restore();
156 332 result.map(|_| 0)
157 333 }
158 334
159 - fn event_loop(terminal: &mut ratatui::DefaultTerminal, pools: &[Pool]) -> Result<()> {
160 - let mut selected: usize = 0;
161 - let footer = Footer::new([hint("j/k", "move"), hint("q", "quit")]);
335 + fn event_loop(
336 + terminal: &mut ratatui::DefaultTerminal,
337 + backend: &dyn Backend,
338 + pools: Vec<Pool>,
339 + ) -> Result<()> {
340 + let mut view = View::Pools { selected: 0 };
162 341 loop {
163 - terminal.draw(|frame| {
164 - let [body, foot] = layout::split(frame.area());
165 - frame.render_widget(list_widget(pools, selected), body);
166 - frame.render_widget(&footer, foot);
167 - })?;
342 + terminal.draw(|frame| draw(frame, &pools, &view))?;
168 343
169 344 let Event::Key(key) = event::read()? else {
170 345 continue;
171 346 };
172 - match classify(key) {
173 - GlobalAction::Quit => return Ok(()),
174 - GlobalAction::Passthrough => match key.code {
175 - KeyCode::Char('j') | KeyCode::Down if selected + 1 < pools.len() => {
176 - selected += 1;
177 - }
178 - KeyCode::Char('k') | KeyCode::Up => {
179 - selected = selected.saturating_sub(1);
180 - }
347 + let action = classify(key);
348 +
349 + match &mut view {
350 + View::Pools { selected } => match action {
351 + GlobalAction::Quit => return Ok(()),
352 + GlobalAction::Passthrough => match key.code {
353 + KeyCode::Char('j') | KeyCode::Down if *selected + 1 < pools.len() => {
354 + *selected += 1;
355 + }
356 + KeyCode::Char('k') | KeyCode::Up => {
357 + *selected = selected.saturating_sub(1);
358 + }
359 + KeyCode::Enter if !pools.is_empty() => {
360 + let pool = pools[*selected].name.clone();
361 + let status = backend.pool_status(&pool)?;
362 + view = View::Detail { pool, status };
363 + }
364 + KeyCode::Char('d') => {
365 + let datasets = backend.datasets().unwrap_or_default();
366 + view = View::Datasets { selected: 0, datasets };
367 + }
368 + _ => {}
369 + },
370 + _ => {}
371 + },
372 + View::Detail { .. } => match action {
373 + GlobalAction::Quit => return Ok(()),
374 + GlobalAction::Back => view = View::Pools { selected: 0 },
375 + _ => {}
376 + },
377 + View::Datasets { selected, datasets } => match action {
378 + GlobalAction::Quit => return Ok(()),
379 + GlobalAction::Back => view = View::Pools { selected: 0 },
380 + GlobalAction::Passthrough => match key.code {
381 + KeyCode::Char('j') | KeyCode::Down if *selected + 1 < datasets.len() => {
382 + *selected += 1;
383 + }
384 + KeyCode::Char('k') | KeyCode::Up => {
385 + *selected = selected.saturating_sub(1);
386 + }
387 + _ => {}
388 + },
181 389 _ => {}
182 390 },
183 - _ => {}
184 391 }
185 392 }
186 393 }
187 394
188 - fn list_widget(pools: &[Pool], selected: usize) -> Paragraph<'_> {
395 + fn draw(frame: &mut Frame, pools: &[Pool], view: &View) {
396 + let [body, foot] = layout::split(frame.area());
397 + match view {
398 + View::Pools { selected } => {
399 + frame.render_widget(pools_widget(pools, *selected), body);
400 + frame.render_widget(
401 + &Footer::new([
402 + hint("j/k", "move"),
403 + hint("Enter", "details"),
404 + hint("d", "datasets"),
405 + hint("q", "quit"),
406 + ]),
407 + foot,
408 + );
409 + }
410 + View::Detail { pool, status } => {
411 + draw_detail(frame, pool, status, body);
412 + frame.render_widget(
413 + &Footer::new([hint("Esc", "back"), hint("q", "quit")]),
414 + foot,
415 + );
416 + }
417 + View::Datasets { selected, datasets } => {
418 + frame.render_widget(datasets_widget(datasets, *selected), body);
419 + frame.render_widget(
420 + &Footer::new([
421 + hint("j/k", "move"),
422 + hint("Esc", "back"),
423 + hint("q", "quit"),
424 + ]),
425 + foot,
426 + );
427 + }
428 + }
429 + }
430 +
431 + fn pools_widget(pools: &[Pool], selected: usize) -> Paragraph<'_> {
189 432 let name_w = pools.iter().map(|p| p.name.len()).max().unwrap_or(0).max(4);
190 433 let lines: Vec<Line> = pools
191 434 .iter()
@@ -213,3 +456,100 @@ fn list_widget(pools: &[Pool], selected: usize) -> Paragraph<'_> {
213 456 .collect();
214 457 Paragraph::new(lines).style(Style::default().bg(palette::BG).fg(palette::FG))
215 458 }
459 +
460 + fn draw_detail(frame: &mut Frame, pool: &str, status: &PoolStatus, area: Rect) {
461 + let mut lines: Vec<Line> = Vec::new();
462 + lines.push(Line::from(vec![
463 + Span::raw(" "),
464 + style::dim("pool "),
465 + style::fg(pool.to_string()),
466 + ]));
467 + let scan_text = status.scan.clone().unwrap_or_else(|| "—".into());
468 + lines.push(Line::from(vec![
469 + Span::raw(" "),
470 + style::dim("scan "),
471 + style::fg(scan_text),
472 + ]));
473 + if let Some(err) = &status.errors {
474 + lines.push(Line::from(vec![
475 + Span::raw(" "),
476 + style::dim("errors "),
477 + style::fg(err.clone()),
478 + ]));
479 + }
480 + lines.push(Line::from(""));
481 +
482 + let name_w = status
483 + .vdevs
484 + .iter()
485 + .map(|v| v.indent + v.name.len())
486 + .max()
487 + .unwrap_or(0)
488 + .max(8);
489 +
490 + lines.push(Line::from(vec![
491 + Span::raw(" "),
492 + style::dim(format!("{:<name_w$} STATE READ WRITE CKSUM", "NAME")),
493 + ]));
494 + for v in &status.vdevs {
495 + let name = format!("{:indent$}{}", "", v.name, indent = v.indent);
496 + lines.push(Line::from(vec![
497 + Span::raw(" "),
498 + style::fg(format!("{:<name_w$}", name)),
499 + Span::raw(" "),
500 + v.state.span(),
501 + Span::raw(" "),
502 + error_span(v.read_errors),
503 + Span::raw(" "),
504 + error_span(v.write_errors),
505 + Span::raw(" "),
506 + error_span(v.cksum_errors),
507 + ]));
508 + }
509 +
510 + let para = Paragraph::new(lines).style(Style::default().bg(palette::BG).fg(palette::FG));
511 + frame.render_widget(para, area);
512 + }
513 +
514 + fn error_span(n: u64) -> Span<'static> {
515 + let s = format!("{:>5}", n);
516 + if n == 0 {
517 + style::dim(s)
518 + } else {
519 + style::fault(s)
520 + }
521 + }
522 +
523 + fn datasets_widget(datasets: &[Dataset], selected: usize) -> Paragraph<'_> {
524 + let name_w = datasets.iter().map(|d| d.name.len()).max().unwrap_or(0).max(8);
525 + let mp_w = datasets
526 + .iter()
527 + .map(|d| d.mountpoint.len())
528 + .max()
529 + .unwrap_or(0)
530 + .max(8);
531 + let lines: Vec<Line> = datasets
532 + .iter()
533 + .enumerate()
534 + .map(|(i, d)| {
535 + let is_sel = i == selected;
536 + let marker = if is_sel { selection::MARKER } else { " " };
537 + let line = Line::from(vec![
538 + Span::raw(format!(" {marker} ")),
539 + Span::raw(format!("{:<name_w$}", d.name)),
540 + Span::raw(" "),
541 + style::dim(format!("{:>6}", d.used)),
542 + Span::raw(" / "),
543 + style::dim(format!("{:>6}", d.avail)),
544 + Span::raw(" "),
545 + style::dim(format!("{:<mp_w$}", d.mountpoint)),
546 + ]);
547 + if is_sel {
548 + line.style(selection::selected_style())
549 + } else {
550 + line
551 + }
552 + })
553 + .collect();
554 + Paragraph::new(lines).style(Style::default().bg(palette::BG).fg(palette::FG))
555 + }