use anyhow::{Context, Result}; use crossterm::event::{self, Event, KeyCode}; use ratatui::style::Style; use ratatui::text::{Line, Span}; use ratatui::widgets::Paragraph; use std::process::Command; use sysop_tui::{Footer, GlobalAction, classify, hint, layout, palette, selection, style}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum State { Online, Degraded, Faulted, Offline, Unavail, Suspended, } impl State { fn parse(s: &str) -> Self { match s { "ONLINE" => State::Online, "DEGRADED" => State::Degraded, "FAULTED" => State::Faulted, "OFFLINE" => State::Offline, "UNAVAIL" => State::Unavail, "SUSPENDED" => State::Suspended, _ => State::Faulted, } } fn label(&self) -> &'static str { match self { State::Online => "ONLINE", State::Degraded => "DEGRADED", State::Faulted => "FAULTED", State::Offline => "OFFLINE", State::Unavail => "UNAVAIL", State::Suspended => "SUSPENDED", } } fn span(&self) -> Span<'static> { match self { State::Online => style::ok(self.label()), State::Degraded => style::warn(self.label()), State::Faulted | State::Unavail | State::Suspended => style::fault(self.label()), State::Offline => style::inactive(self.label()), } } fn is_healthy(&self) -> bool { matches!(self, State::Online) } } #[derive(Debug, Clone)] pub struct Pool { pub name: String, pub state: State, pub size: String, pub alloc: String, pub capacity_pct: String, } pub trait Backend { fn list(&self) -> Result>; } fn detect() -> Box { if Zfs.probe().is_ok() { Box::new(Zfs) } else { Box::new(Mock) } } struct Zfs; impl Zfs { fn probe(&self) -> Result<()> { let out = Command::new("zpool") .arg("-V") .output() .context("invoke zpool")?; if out.status.success() { Ok(()) } else { anyhow::bail!("zpool unresponsive") } } } impl Backend for Zfs { fn list(&self) -> Result> { let out = Command::new("zpool") .args(["list", "-H", "-o", "name,health,size,alloc,capacity"]) .output() .context("zpool list")?; if !out.status.success() { anyhow::bail!("zpool list failed"); } let mut pools = Vec::new(); for line in String::from_utf8_lossy(&out.stdout).lines() { let cols: Vec<&str> = line.split('\t').collect(); if cols.len() < 5 { continue; } pools.push(Pool { name: cols[0].to_string(), state: State::parse(cols[1]), size: cols[2].to_string(), alloc: cols[3].to_string(), capacity_pct: cols[4].to_string(), }); } Ok(pools) } } struct Mock; impl Backend for Mock { fn list(&self) -> Result> { Ok(vec![ Pool { name: "rpool".into(), state: State::Online, size: "476G".into(), alloc: "62.1G".into(), capacity_pct: "13%".into(), }, Pool { name: "tank".into(), state: State::Degraded, size: "1.81T".into(), alloc: "1.20T".into(), capacity_pct: "66%".into(), }, ]) } } pub fn run(summary: bool) -> Result { let pools = detect().list()?; if summary { let healthy = pools.iter().all(|p| p.state.is_healthy()); println!("{}", if healthy { "ok" } else { "degraded" }); return Ok(0); } let mut terminal = ratatui::init(); let result = event_loop(&mut terminal, &pools); ratatui::restore(); result.map(|_| 0) } fn event_loop(terminal: &mut ratatui::DefaultTerminal, pools: &[Pool]) -> Result<()> { let mut selected: usize = 0; let footer = Footer::new([hint("j/k", "move"), hint("q", "quit")]); loop { terminal.draw(|frame| { let [body, foot] = layout::split(frame.area()); frame.render_widget(list_widget(pools, selected), body); frame.render_widget(&footer, foot); })?; let Event::Key(key) = event::read()? else { continue; }; match classify(key) { GlobalAction::Quit => return Ok(()), GlobalAction::Passthrough => match key.code { KeyCode::Char('j') | KeyCode::Down if selected + 1 < pools.len() => { selected += 1; } KeyCode::Char('k') | KeyCode::Up => { selected = selected.saturating_sub(1); } _ => {} }, _ => {} } } } fn list_widget(pools: &[Pool], selected: usize) -> Paragraph<'_> { let name_w = pools.iter().map(|p| p.name.len()).max().unwrap_or(0).max(4); let lines: Vec = pools .iter() .enumerate() .map(|(i, p)| { let is_sel = i == selected; let marker = if is_sel { selection::MARKER } else { " " }; let line = Line::from(vec![ Span::raw(format!(" {marker} ")), Span::raw(format!("{: