max / mountaineer
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 | + | } |