first commit

This commit is contained in:
2026-04-13 22:17:10 -06:00
commit 18e22b2207
10 changed files with 3600 additions and 0 deletions

1736
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

9
Cargo.toml Normal file
View File

@@ -0,0 +1,9 @@
[package]
name = "zfs-stats"
version = "0.1.0"
edition = "2021"
[dependencies]
ratatui = "0.30"
crossterm = "0.29"
anyhow = "1"

515
src/app.rs Normal file
View File

@@ -0,0 +1,515 @@
use std::collections::{HashMap, VecDeque};
use std::time::Instant;
use crate::netio::{classify, is_chart_worthy, read_net_dev, IfaceKind, NetIfaceRaw};
use crate::pools::{list_pool_names, read_all_pool_space, read_pool_io, PoolIoRaw, PoolSpace};
use crate::procs::{read_all_proc_io, ProcIoRaw};
use crate::smart::{
read_hwmon, read_smart, read_smart_sudo, scan_drives, scan_drives_sudo, test_sudo_password,
};
use crate::zfs::{read_arcstats, ArcStats};
pub const HISTORY_SIZE: usize = 120;
/// Indices in the left-panel list
pub const _ARC_METRICS_COUNT: usize = 6;
pub const POOL_IO_IDX: usize = 6;
pub const NETWORK_IDX: usize = 7;
pub const SMART_IDX: usize = 8;
pub const TOTAL_ITEMS: usize = 9;
// ── ARC metric definitions ────────────────────────────────────────────────────
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum MetricKind {
ArcHitRatio,
L2HitRatio,
ArcL2Rate,
ArcL2CacheUsage,
ArcSize,
ArcL2Size,
}
pub struct MetricDef {
pub kind: MetricKind,
pub name: &'static str,
pub unit: &'static str,
pub description: &'static str,
}
pub const METRICS: &[MetricDef] = &[
MetricDef {
kind: MetricKind::ArcHitRatio,
name: "ARC Hit Ratio",
unit: "%",
description: "ARC cache hits as a percentage of all ARC requests.\nA high value means most reads are served from RAM.",
},
MetricDef {
kind: MetricKind::L2HitRatio,
name: "L2 Hit Ratio",
unit: "%",
description: "L2ARC hits as a percentage of all L2ARC lookups.\nShows how effective the L2 SSD cache is.",
},
MetricDef {
kind: MetricKind::ArcL2Rate,
name: "ARC L2 Rate",
unit: "MB/s",
description: "Read throughput from the L2ARC (SSD) cache.\nHigher means more data is being served from L2.",
},
MetricDef {
kind: MetricKind::ArcL2CacheUsage,
name: "L2 Cache Usage",
unit: "%",
description: "L2ARC used size as a percentage of the peak L2 size\nobserved since startup.",
},
MetricDef {
kind: MetricKind::ArcSize,
name: "ARC Size",
unit: "MB / GB",
description: "Current size of the Adaptive Replacement Cache (ARC)\nstored in RAM.",
},
MetricDef {
kind: MetricKind::ArcL2Size,
name: "ARC L2 Size",
unit: "MB / GB",
description: "Current logical size of the L2ARC stored on\nthe SSD/NVMe cache device.",
},
];
// ── Display types ─────────────────────────────────────────────────────────────
#[derive(Debug, Clone, Default)]
pub struct PoolDisplay {
pub name: String,
pub size_bytes: u64,
pub alloc_bytes: u64,
pub health: String,
pub read_mb_s: f64,
pub write_mb_s: f64,
pub read_iops: f64,
pub write_iops: f64,
}
#[derive(Debug, Clone, Default)]
pub struct ProcDisplay {
pub pid: u32,
pub name: String,
pub read_mb_s: f64,
pub write_mb_s: f64,
}
#[derive(Debug, Clone, Default)]
pub struct NetIfaceDisplay {
pub name: String,
pub kind: IfaceKind,
pub rx_mb_s: f64,
pub tx_mb_s: f64,
pub rx_pps: f64,
pub tx_pps: f64,
pub rx_errors: u64,
pub tx_errors: u64,
}
impl Default for IfaceKind {
fn default() -> Self {
IfaceKind::Other
}
}
pub use crate::smart::{DriveType, HwmonSensor, SmartDrive};
// ── Password popup ────────────────────────────────────────────────────────────
#[derive(Debug, Default)]
pub struct PasswordPopup {
pub input: String,
pub error: Option<String>,
}
impl PasswordPopup {
pub fn push(&mut self, c: char) {
self.input.push(c);
self.error = None; // clear error on new input
}
pub fn backspace(&mut self) {
self.input.pop();
self.error = None;
}
}
// ── App state ─────────────────────────────────────────────────────────────────
pub struct App {
pub selected: usize,
// ARC metrics
pub history: Vec<VecDeque<f64>>,
pub current_values: Vec<f64>,
pub error: Option<String>,
// Pool I/O
pub pools: Vec<PoolDisplay>,
pub pool_error: Option<String>,
// Top I/O processes
pub top_procs: Vec<ProcDisplay>,
// Network I/O
pub net_ifaces: Vec<NetIfaceDisplay>,
pub net_rx_history: VecDeque<f64>,
pub net_tx_history: VecDeque<f64>,
// SMART / hwmon
pub hwmon: Vec<HwmonSensor>,
pub smart_drives: Vec<Result<SmartDrive, String>>,
pub smart_permission_error: bool,
// Sudo password popup
pub popup: Option<PasswordPopup>,
sudo_password: Option<String>,
// Delta-tracking state (private)
last_stats: Option<ArcStats>,
last_time: Option<Instant>,
peak_l2_size_mb: f64,
last_pool_io: HashMap<String, PoolIoRaw>,
last_proc_io: HashMap<u32, ProcIoRaw>,
last_net_raw: HashMap<String, NetIfaceRaw>,
pool_space_cache: Vec<PoolSpace>,
space_tick: u32,
smart_tick: u32,
smart_devices: Vec<String>,
}
impl App {
pub fn new() -> Self {
Self {
selected: 0,
history: vec![VecDeque::new(); METRICS.len()],
current_values: vec![0.0; METRICS.len()],
error: None,
pools: Vec::new(),
pool_error: None,
top_procs: Vec::new(),
net_ifaces: Vec::new(),
net_rx_history: VecDeque::new(),
net_tx_history: VecDeque::new(),
hwmon: Vec::new(),
smart_drives: Vec::new(),
smart_permission_error: false,
popup: None,
sudo_password: None,
last_stats: None,
last_time: None,
peak_l2_size_mb: 1.0,
last_pool_io: HashMap::new(),
last_proc_io: HashMap::new(),
last_net_raw: HashMap::new(),
pool_space_cache: Vec::new(),
space_tick: 0,
smart_tick: 0,
smart_devices: Vec::new(),
}
}
pub fn next(&mut self) {
if self.selected + 1 < TOTAL_ITEMS {
self.selected += 1;
}
}
pub fn prev(&mut self) {
if self.selected > 0 {
self.selected -= 1;
}
}
pub fn is_popup_open(&self) -> bool {
self.popup.is_some()
}
pub fn open_password_popup(&mut self) {
self.popup = Some(PasswordPopup::default());
}
pub fn close_popup(&mut self) {
self.popup = None;
}
pub fn popup_push(&mut self, c: char) {
if let Some(p) = &mut self.popup {
p.push(c);
}
}
pub fn popup_backspace(&mut self) {
if let Some(p) = &mut self.popup {
p.backspace();
}
}
/// Try the typed password. On success closes the popup and schedules a
/// SMART refresh. On failure stores the error message in the popup.
/// Returns true if the password was accepted.
pub fn submit_password(&mut self) -> bool {
let password = match &self.popup {
Some(p) => p.input.clone(),
None => return false,
};
match test_sudo_password(&password) {
Ok(()) => {
self.sudo_password = Some(password);
self.popup = None;
self.smart_permission_error = false;
// Force an immediate SMART re-read on next tick
self.smart_tick = 0;
self.smart_devices.clear();
true
}
Err(e) => {
if let Some(p) = &mut self.popup {
p.input.clear();
p.error = Some(e);
}
false
}
}
}
pub fn update_stats(&mut self) {
let now = Instant::now();
self.update_arc(now);
self.update_pool_io(now);
self.update_proc_io(now);
self.update_network(now);
self.update_smart();
self.last_time = Some(now);
}
// ── ARC ───────────────────────────────────────────────────────────────────
fn update_arc(&mut self, now: Instant) {
match read_arcstats() {
Ok(stats) => {
self.error = None;
let vals = self.compute_arc(&stats, now);
for (i, v) in vals.iter().enumerate() {
self.current_values[i] = *v;
self.history[i].push_back(*v);
if self.history[i].len() > HISTORY_SIZE {
self.history[i].pop_front();
}
}
self.last_stats = Some(stats);
}
Err(e) => self.error = Some(e.to_string()),
}
}
fn compute_arc(&mut self, s: &ArcStats, now: Instant) -> Vec<f64> {
let mut v = vec![0.0f64; METRICS.len()];
let total = s.hits + s.misses;
v[0] = if total > 0 { s.hits as f64 / total as f64 * 100.0 } else { 0.0 };
let l2_total = s.l2_hits + s.l2_misses;
v[1] = if l2_total > 0 { s.l2_hits as f64 / l2_total as f64 * 100.0 } else { 0.0 };
if let (Some(last), Some(lt)) = (&self.last_stats, self.last_time) {
let elapsed = now.duration_since(lt).as_secs_f64();
if elapsed > 0.0 {
let delta = s.l2_read_bytes.saturating_sub(last.l2_read_bytes);
v[2] = delta as f64 / elapsed / 1_048_576.0;
}
}
let l2_mb = s.l2_size as f64 / 1_048_576.0;
if l2_mb > self.peak_l2_size_mb { self.peak_l2_size_mb = l2_mb; }
v[3] = if self.peak_l2_size_mb > 0.0 { l2_mb / self.peak_l2_size_mb * 100.0 } else { 0.0 };
v[4] = s.size as f64 / 1_048_576.0;
v[5] = s.l2_size as f64 / 1_048_576.0;
v
}
// ── Pool I/O ──────────────────────────────────────────────────────────────
fn update_pool_io(&mut self, now: Instant) {
let elapsed = self.last_time.map(|t| now.duration_since(t).as_secs_f64()).unwrap_or(1.0).max(0.001);
if self.space_tick == 0 { self.pool_space_cache = read_all_pool_space(); }
self.space_tick = (self.space_tick + 1) % 10;
let names = list_pool_names();
let mut new_io: HashMap<String, PoolIoRaw> = HashMap::new();
let mut displays: Vec<PoolDisplay> = Vec::new();
let space_map: HashMap<&str, &PoolSpace> = self.pool_space_cache.iter().map(|s| (s.name.as_str(), s)).collect();
let mut had_error = false;
for name in &names {
match read_pool_io(name) {
Ok(raw) => {
let prev = self.last_pool_io.get(name.as_str());
let read_mb_s = prev.map(|p| raw.arc_read_bytes.saturating_sub(p.arc_read_bytes) as f64 / elapsed / 1_048_576.0).unwrap_or(0.0);
let write_mb_s = prev.map(|p| raw.arc_write_bytes.saturating_sub(p.arc_write_bytes) as f64 / elapsed / 1_048_576.0).unwrap_or(0.0);
let read_iops = prev.map(|p| raw.arc_read_count.saturating_sub(p.arc_read_count) as f64 / elapsed).unwrap_or(0.0);
let write_iops = prev.map(|p| raw.arc_write_count.saturating_sub(p.arc_write_count) as f64 / elapsed).unwrap_or(0.0);
let sp = space_map.get(name.as_str());
displays.push(PoolDisplay {
name: name.clone(),
size_bytes: sp.map(|s| s.size_bytes).unwrap_or(0),
alloc_bytes: sp.map(|s| s.alloc_bytes).unwrap_or(0),
health: sp.map(|s| s.health.clone()).unwrap_or_else(|| "?".into()),
read_mb_s, write_mb_s, read_iops, write_iops,
});
new_io.insert(name.clone(), raw);
}
Err(_) => had_error = true,
}
}
displays.sort_by(|a, b| (b.read_mb_s + b.write_mb_s).partial_cmp(&(a.read_mb_s + a.write_mb_s)).unwrap_or(std::cmp::Ordering::Equal));
self.pools = displays;
self.last_pool_io = new_io;
self.pool_error = had_error.then(|| "Some pools could not be read".into());
}
// ── Process I/O ───────────────────────────────────────────────────────────
fn update_proc_io(&mut self, now: Instant) {
let elapsed = self.last_time.map(|t| now.duration_since(t).as_secs_f64()).unwrap_or(1.0).max(0.001);
let current = read_all_proc_io();
let mut new_map: HashMap<u32, ProcIoRaw> = HashMap::new();
let mut rates: Vec<ProcDisplay> = Vec::new();
for proc in current {
let prev = self.last_proc_io.get(&proc.pid);
let rmb = prev.map(|p| proc.read_bytes.saturating_sub(p.read_bytes) as f64 / elapsed / 1_048_576.0).unwrap_or(0.0);
let wmb = prev.map(|p| proc.write_bytes.saturating_sub(p.write_bytes) as f64 / elapsed / 1_048_576.0).unwrap_or(0.0);
if rmb > 0.0 || wmb > 0.0 {
rates.push(ProcDisplay { pid: proc.pid, name: proc.name.clone(), read_mb_s: rmb, write_mb_s: wmb });
}
new_map.insert(proc.pid, proc);
}
rates.sort_by(|a, b| (b.read_mb_s + b.write_mb_s).partial_cmp(&(a.read_mb_s + a.write_mb_s)).unwrap_or(std::cmp::Ordering::Equal));
rates.truncate(20);
self.top_procs = rates;
self.last_proc_io = new_map;
}
// ── Network I/O ───────────────────────────────────────────────────────────
fn update_network(&mut self, now: Instant) {
let elapsed = self.last_time.map(|t| now.duration_since(t).as_secs_f64()).unwrap_or(1.0).max(0.001);
let raw_list = read_net_dev();
let mut new_raw: HashMap<String, NetIfaceRaw> = HashMap::new();
let mut displays: Vec<NetIfaceDisplay> = Vec::new();
let mut agg_rx = 0.0f64;
let mut agg_tx = 0.0f64;
for r in &raw_list {
let kind = classify(&r.name);
let prev = self.last_net_raw.get(&r.name);
let rx_mb_s = prev.map(|p| r.rx_bytes.saturating_sub(p.rx_bytes) as f64 / elapsed / 1_048_576.0).unwrap_or(0.0);
let tx_mb_s = prev.map(|p| r.tx_bytes.saturating_sub(p.tx_bytes) as f64 / elapsed / 1_048_576.0).unwrap_or(0.0);
let rx_pps = prev.map(|p| r.rx_packets.saturating_sub(p.rx_packets) as f64 / elapsed).unwrap_or(0.0);
let tx_pps = prev.map(|p| r.tx_packets.saturating_sub(p.tx_packets) as f64 / elapsed).unwrap_or(0.0);
if is_chart_worthy(kind) {
agg_rx += rx_mb_s;
agg_tx += tx_mb_s;
}
// Include in table: loopback excluded; containers only if active
let include = match kind {
IfaceKind::Loopback => false,
IfaceKind::Container => rx_mb_s > 0.0 || tx_mb_s > 0.0,
_ => true,
};
if include {
displays.push(NetIfaceDisplay {
name: r.name.clone(), kind,
rx_mb_s, tx_mb_s, rx_pps, tx_pps,
rx_errors: r.rx_errors, tx_errors: r.tx_errors,
});
}
new_raw.insert(r.name.clone(), r.clone());
}
// Sort: physical/vpn first, then by total rate
displays.sort_by(|a, b| {
let pri_a = kind_priority(a.kind);
let pri_b = kind_priority(b.kind);
if pri_a != pri_b { return pri_a.cmp(&pri_b); }
(b.rx_mb_s + b.tx_mb_s).partial_cmp(&(a.rx_mb_s + a.tx_mb_s)).unwrap_or(std::cmp::Ordering::Equal)
});
self.net_ifaces = displays;
self.last_net_raw = new_raw;
// Append to history (aggregate of non-container interfaces)
self.net_rx_history.push_back(agg_rx);
self.net_tx_history.push_back(agg_tx);
if self.net_rx_history.len() > HISTORY_SIZE { self.net_rx_history.pop_front(); }
if self.net_tx_history.len() > HISTORY_SIZE { self.net_tx_history.pop_front(); }
}
// ── SMART / hwmon ─────────────────────────────────────────────────────────
fn update_smart(&mut self) {
// hwmon: fast, always readable without privileges
self.hwmon = read_hwmon();
// SMART: expensive — poll every 60 s
if self.smart_tick == 0 {
let pwd = self.sudo_password.clone();
// Discover devices (use sudo path if password is known)
if self.smart_devices.is_empty() {
self.smart_devices = match &pwd {
Some(p) => scan_drives_sudo(p).unwrap_or_else(|_| scan_drives()),
None => scan_drives(),
};
}
self.smart_permission_error = false;
self.smart_drives = self.smart_devices.iter().map(|d| {
let result = match &pwd {
Some(p) => read_smart_sudo(d, p),
None => read_smart(d),
};
if let Err(ref e) = result {
if e.contains("Permission denied") || e.contains("sudo password") {
self.smart_permission_error = true;
}
}
result
}).collect();
}
self.smart_tick = (self.smart_tick + 1) % 60;
}
// ── Helpers ───────────────────────────────────────────────────────────────
pub fn history_stats(&self) -> (f64, f64, f64) {
let h = &self.history[self.selected];
if h.is_empty() { return (0.0, 0.0, 0.0); }
let min = h.iter().cloned().fold(f64::MAX, f64::min);
let max = h.iter().cloned().fold(f64::MIN, f64::max);
let avg = h.iter().sum::<f64>() / h.len() as f64;
(min, max, avg)
}
}
fn kind_priority(k: IfaceKind) -> u8 {
match k {
IfaceKind::Physical => 0,
IfaceKind::Vpn => 1,
IfaceKind::Virtual => 2,
IfaceKind::Other => 3,
IfaceKind::Container => 4,
IfaceKind::Loopback => 5,
}
}

93
src/main.rs Normal file
View File

@@ -0,0 +1,93 @@
use std::io;
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::Duration;
use crossterm::event::{self, Event, KeyCode, KeyEventKind};
use crossterm::execute;
use crossterm::terminal::{
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
};
use ratatui::backend::CrosstermBackend;
use ratatui::Terminal;
mod app;
mod netio;
mod pools;
mod procs;
mod smart;
mod ui;
mod zfs;
use app::{App, SMART_IDX};
fn main() -> anyhow::Result<()> {
// Set up terminal
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
// Shared app state — updated by a background thread, read on the main thread
let app = Arc::new(Mutex::new(App::new()));
// Background stats collector: reads arcstats every second
{
let app = Arc::clone(&app);
thread::spawn(move || loop {
{
if let Ok(mut a) = app.lock() {
a.update_stats();
}
}
thread::sleep(Duration::from_secs(1));
});
}
// Main event + render loop
loop {
{
let app = app.lock().unwrap();
terminal.draw(|f| ui::draw(f, &app))?;
}
// Poll for input with a short timeout so the UI refreshes smoothly
if event::poll(Duration::from_millis(250))? {
if let Event::Key(key) = event::read()? {
if key.kind == KeyEventKind::Press {
let mut app = app.lock().unwrap();
if app.is_popup_open() {
// All keys are consumed by the popup while it is open
match key.code {
KeyCode::Esc => app.close_popup(),
KeyCode::Enter => { app.submit_password(); }
KeyCode::Backspace => app.popup_backspace(),
KeyCode::Char(c) => app.popup_push(c),
_ => {}
}
} else {
match key.code {
KeyCode::Char('q') | KeyCode::Esc => break,
KeyCode::Down | KeyCode::Char('j') => app.next(),
KeyCode::Up | KeyCode::Char('k') => app.prev(),
// 'p' opens the sudo password popup from the SMART panel
KeyCode::Char('p') if app.selected == SMART_IDX => {
app.open_password_popup();
}
_ => {}
}
}
}
}
}
}
// Restore terminal
disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
terminal.show_cursor()?;
Ok(())
}

94
src/netio.rs Normal file
View File

@@ -0,0 +1,94 @@
use std::fs;
// ── Raw cumulative counters from /proc/net/dev ────────────────────────────────
#[derive(Debug, Clone, Default)]
pub struct NetIfaceRaw {
pub name: String,
pub rx_bytes: u64,
pub rx_packets: u64,
pub rx_errors: u64,
pub tx_bytes: u64,
pub tx_packets: u64,
pub tx_errors: u64,
}
/// Read all interface counters. Returns empty vec on parse failure.
pub fn read_net_dev() -> Vec<NetIfaceRaw> {
let Ok(content) = fs::read_to_string("/proc/net/dev") else {
return Vec::new();
};
content.lines().skip(2).filter_map(parse_line).collect()
}
fn parse_line(line: &str) -> Option<NetIfaceRaw> {
// " iface: rx_bytes rx_pkts rx_errs rx_drop ... tx_bytes tx_pkts tx_errs ..."
let (name_part, data_part) = line.split_once(':')?;
let name = name_part.trim().to_string();
let nums: Vec<u64> = data_part
.split_whitespace()
.filter_map(|s| s.parse().ok())
.collect();
if nums.len() < 11 {
return None;
}
Some(NetIfaceRaw {
name,
rx_bytes: nums[0],
rx_packets: nums[1],
rx_errors: nums[2],
tx_bytes: nums[8],
tx_packets: nums[9],
tx_errors: nums[10],
})
}
// ── Interface classifier ──────────────────────────────────────────────────────
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum IfaceKind {
Loopback,
Physical, // enp*, eth*, ens*, eno*, em*, wlan*, wlp*, bond*
Vpn, // tun*, tap*, tailscale*, wg*, ipsec*
Virtual, // macvlan*, macvtap*, vlan*
Container, // br-*, veth*, docker*, virbr*
Other,
}
pub fn classify(name: &str) -> IfaceKind {
if name == "lo" {
return IfaceKind::Loopback;
}
let prefixes_physical = ["eth", "enp", "ens", "eno", "em", "wlan", "wlp", "bond", "team"];
let prefixes_vpn = ["tun", "tap", "tailscale", "wg", "ipsec", "ppp"];
let prefixes_virtual = ["macvlan", "macvtap", "vlan"];
let prefixes_container = ["br-", "veth", "docker", "virbr", "lxcbr", "lxdbr"];
for p in prefixes_physical {
if name.starts_with(p) {
return IfaceKind::Physical;
}
}
for p in prefixes_vpn {
if name.starts_with(p) {
return IfaceKind::Vpn;
}
}
for p in prefixes_virtual {
if name.starts_with(p) {
return IfaceKind::Virtual;
}
}
for p in prefixes_container {
if name.starts_with(p) {
return IfaceKind::Container;
}
}
IfaceKind::Other
}
/// True for interfaces we want to include in the aggregate chart line
/// (everything except loopback and container internals).
pub fn is_chart_worthy(kind: IfaceKind) -> bool {
!matches!(kind, IfaceKind::Loopback | IfaceKind::Container)
}

100
src/pools.rs Normal file
View File

@@ -0,0 +1,100 @@
use std::collections::HashMap;
use std::fs;
use std::process::Command;
// ── Raw kstat snapshot ────────────────────────────────────────────────────────
#[derive(Debug, Clone, Default)]
pub struct PoolIoRaw {
pub arc_read_bytes: u64,
pub arc_write_bytes: u64,
pub arc_read_count: u64,
pub arc_write_count: u64,
}
// ── Space info from `zpool list` ──────────────────────────────────────────────
#[derive(Debug, Clone)]
pub struct PoolSpace {
pub name: String,
pub size_bytes: u64,
pub alloc_bytes: u64,
pub health: String,
}
// ── Public helpers ────────────────────────────────────────────────────────────
/// Find pool names by looking for `iostats` files under /proc/spl/kstat/zfs/<pool>/
pub fn list_pool_names() -> Vec<String> {
let mut pools = Vec::new();
if let Ok(entries) = fs::read_dir("/proc/spl/kstat/zfs") {
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() && path.join("iostats").exists() {
if let Some(name) = path.file_name() {
pools.push(name.to_string_lossy().to_string());
}
}
}
}
pools.sort();
pools
}
/// Read cumulative I/O counters for one pool.
pub fn read_pool_io(pool_name: &str) -> anyhow::Result<PoolIoRaw> {
let content =
fs::read_to_string(format!("/proc/spl/kstat/zfs/{}/iostats", pool_name))?;
let mut map = HashMap::new();
for line in content.lines().skip(2) {
let mut parts = line.split_whitespace();
let name = match parts.next() {
Some(n) => n,
None => continue,
};
parts.next(); // skip type column
if let Some(val_str) = parts.next() {
if let Ok(val) = val_str.parse::<u64>() {
map.insert(name.to_string(), val);
}
}
}
Ok(PoolIoRaw {
arc_read_bytes: map.get("arc_read_bytes").copied().unwrap_or(0),
arc_write_bytes: map.get("arc_write_bytes").copied().unwrap_or(0),
arc_read_count: map.get("arc_read_count").copied().unwrap_or(0),
arc_write_count: map.get("arc_write_count").copied().unwrap_or(0),
})
}
/// Fetch pool space info for all pools via `zpool list`.
/// Returns an empty vec if zpool is unavailable.
pub fn read_all_pool_space() -> Vec<PoolSpace> {
let out = Command::new("zpool")
.args(["list", "-Hp", "-o", "name,size,alloc,free,health"])
.output();
let Ok(out) = out else { return Vec::new() };
if !out.status.success() {
return Vec::new();
}
String::from_utf8_lossy(&out.stdout)
.lines()
.filter_map(|line| {
let parts: Vec<&str> = line.split('\t').collect();
if parts.len() >= 5 {
Some(PoolSpace {
name: parts[0].to_string(),
size_bytes: parts[1].parse().unwrap_or(0),
alloc_bytes: parts[2].parse().unwrap_or(0),
health: parts[4].to_string(),
})
} else {
None
}
})
.collect()
}

49
src/procs.rs Normal file
View File

@@ -0,0 +1,49 @@
use std::fs;
#[derive(Debug, Clone, Default)]
pub struct ProcIoRaw {
pub pid: u32,
pub name: String,
/// Actual bytes read from storage (not page-cache)
pub read_bytes: u64,
/// Actual bytes written to storage
pub write_bytes: u64,
}
/// Read cumulative I/O for every process we have permission to see.
pub fn read_all_proc_io() -> Vec<ProcIoRaw> {
let Ok(entries) = fs::read_dir("/proc") else {
return Vec::new();
};
entries
.flatten()
.filter_map(|entry| {
let fname = entry.file_name();
let pid: u32 = fname.to_string_lossy().parse().ok()?;
let name = fs::read_to_string(format!("/proc/{}/comm", pid))
.unwrap_or_default()
.trim()
.to_string();
let io_content = fs::read_to_string(format!("/proc/{}/io", pid)).ok()?;
let (read_bytes, write_bytes) = parse_io(&io_content);
Some(ProcIoRaw { pid, name, read_bytes, write_bytes })
})
.collect()
}
fn parse_io(content: &str) -> (u64, u64) {
let mut rb = 0u64;
let mut wb = 0u64;
for line in content.lines() {
if let Some(v) = line.strip_prefix("read_bytes: ") {
rb = v.trim().parse().unwrap_or(0);
} else if let Some(v) = line.strip_prefix("write_bytes: ") {
wb = v.trim().parse().unwrap_or(0);
}
}
(rb, wb)
}

304
src/smart.rs Normal file
View File

@@ -0,0 +1,304 @@
use std::fs;
use std::io::Write;
use std::process::{Command, Stdio};
// ── hwmon temperatures (no root needed) ──────────────────────────────────────
#[derive(Debug, Clone)]
pub struct HwmonSensor {
pub source: String, // e.g. "coretemp", "acpitz"
pub label: String, // e.g. "Core 0", "Package id 0"
pub temp_c: i32,
pub crit_c: Option<i32>,
}
pub fn read_hwmon() -> Vec<HwmonSensor> {
let Ok(entries) = fs::read_dir("/sys/class/hwmon") else {
return Vec::new();
};
let mut sensors = Vec::new();
for entry in entries.flatten() {
let dir = entry.path();
let source = fs::read_to_string(dir.join("name"))
.unwrap_or_default()
.trim()
.to_string();
// Walk temp*_input files
let Ok(files) = fs::read_dir(&dir) else { continue };
let mut inputs: Vec<_> = files
.flatten()
.filter(|e| {
e.file_name()
.to_string_lossy()
.starts_with("temp")
&& e.file_name().to_string_lossy().ends_with("_input")
})
.collect();
inputs.sort_by_key(|e| e.file_name());
for input_entry in inputs {
let input_path = input_entry.path();
let Ok(raw_str) = fs::read_to_string(&input_path) else { continue };
let Ok(raw_mc) = raw_str.trim().parse::<i32>() else { continue };
let temp_c = raw_mc / 1000;
// Label (e.g. "Core 0")
let label_path = input_path
.to_string_lossy()
.replace("_input", "_label");
let label = fs::read_to_string(&label_path)
.unwrap_or_default()
.trim()
.to_string();
let label = if label.is_empty() {
input_path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.replace("_input", "")
.to_string()
} else {
label
};
// Critical threshold (optional)
let crit_path = input_path
.to_string_lossy()
.replace("_input", "_crit");
let crit_c = fs::read_to_string(&crit_path)
.ok()
.and_then(|s| s.trim().parse::<i32>().ok())
.map(|v| v / 1000);
sensors.push(HwmonSensor { source: source.clone(), label, temp_c, crit_c });
}
}
sensors
}
// ── SMART drive data (needs root / elevated perms) ────────────────────────────
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DriveType {
Ata,
Nvme,
Scsi,
Unknown,
}
#[derive(Debug, Clone)]
pub struct SmartDrive {
pub device: String,
pub model: String,
pub drive_type: DriveType,
pub health: String, // "PASSED" | "FAILED" | "N/A"
pub temperature: Option<i32>,
pub power_on_hours: Option<u64>,
pub reallocated: Option<u64>,
pub pending: Option<u64>,
pub uncorrectable: Option<u64>,
}
/// Discover SMART-capable devices via `smartctl --scan` (no privileges needed).
pub fn scan_drives() -> Vec<String> {
let Ok(out) = Command::new("smartctl").arg("--scan").output() else {
return Vec::new();
};
String::from_utf8_lossy(&out.stdout)
.lines()
.filter_map(|l| l.split_whitespace().next().map(str::to_string))
.collect()
}
/// Read SMART data for one device (plain, no sudo).
pub fn read_smart(device: &str) -> Result<SmartDrive, String> {
let out = Command::new("smartctl")
.args(["-A", "-H", "-i", device])
.output()
.map_err(|e| e.to_string())?;
let text = String::from_utf8_lossy(&out.stdout).to_string();
let stderr = String::from_utf8_lossy(&out.stderr).to_string();
if text.contains("Permission denied") || stderr.contains("Permission denied") {
return Err("Permission denied — press 'p' to enter sudo password".into());
}
if text.contains("failed:") && text.len() < 200 {
return Err(text.lines().last().unwrap_or("smartctl error").to_string());
}
Ok(parse_smart(&text, device))
}
// ── sudo-aware variants ───────────────────────────────────────────────────────
/// Run `sudo -S smartctl <args>` feeding `password` via stdin.
/// Returns (stdout, true) on success, (stderr, false) on failure.
fn run_with_sudo(password: &str, smartctl_args: &[&str]) -> Result<String, String> {
let mut child = Command::new("sudo")
.arg("-S") // read password from stdin
.arg("smartctl")
.args(smartctl_args)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| format!("Failed to spawn sudo: {}", e))?;
// Write password + newline then close stdin so sudo doesn't hang
if let Some(mut stdin) = child.stdin.take() {
let _ = write!(stdin, "{}\n", password);
}
let out = child.wait_with_output().map_err(|e| e.to_string())?;
let stdout = String::from_utf8_lossy(&out.stdout).to_string();
let stderr = String::from_utf8_lossy(&out.stderr).to_string();
if stderr.to_lowercase().contains("incorrect password")
|| stderr.to_lowercase().contains("sorry")
|| stderr.to_lowercase().contains("authentication failure")
{
return Err("Incorrect password".into());
}
if !out.status.success() && stdout.is_empty() {
let msg = stderr.lines().last().unwrap_or("sudo failed").trim().to_string();
return Err(msg);
}
Ok(stdout)
}
/// Validate a sudo password by running `sudo -S true` (harmless).
pub fn test_sudo_password(password: &str) -> Result<(), String> {
run_with_sudo(password, &["--scan"]).map(|_| ())
}
/// Discover drives with sudo privileges.
pub fn scan_drives_sudo(password: &str) -> Result<Vec<String>, String> {
let stdout = run_with_sudo(password, &["--scan"])?;
let drives = stdout
.lines()
.filter_map(|l| l.split_whitespace().next().map(str::to_string))
.collect();
Ok(drives)
}
/// Read SMART data for one device using sudo.
pub fn read_smart_sudo(device: &str, password: &str) -> Result<SmartDrive, String> {
let text = run_with_sudo(password, &["-A", "-H", "-i", device])?;
Ok(parse_smart(&text, device))
}
fn parse_smart(text: &str, device: &str) -> SmartDrive {
let mut d = SmartDrive {
device: device.to_string(),
model: String::new(),
drive_type: DriveType::Unknown,
health: "N/A".to_string(),
temperature: None,
power_on_hours: None,
reallocated: None,
pending: None,
uncorrectable: None,
};
for line in text.lines() {
// Drive type detection
if line.contains("NVMe") || line.contains("NVME") {
d.drive_type = DriveType::Nvme;
} else if d.drive_type == DriveType::Unknown
&& (line.contains("SATA") || line.contains("ATA device"))
{
d.drive_type = DriveType::Ata;
} else if d.drive_type == DriveType::Unknown && line.contains("SCSI") {
d.drive_type = DriveType::Scsi;
}
// Model / serial
if let Some(v) = strip_any(line, &["Device Model:", "Model Number:", "Product:"]) {
if d.model.is_empty() {
d.model = v.trim().to_string();
}
}
// Overall health
if line.contains("SMART overall-health") {
d.health = if line.contains("PASSED") {
"PASSED".into()
} else if line.contains("FAILED") {
"FAILED".into()
} else {
"N/A".into()
};
}
// ── NVMe key=value style ──────────────────────────────────────────
if let Some(v) = strip_any(line, &["Temperature:", "Temperature Sensor 1:"]) {
if let Some(t) = first_number(v) {
d.temperature.get_or_insert(t);
}
}
if let Some(v) = line.strip_prefix("Power On Hours:") {
let clean: String = v.chars().filter(|c| c.is_ascii_digit()).collect();
if let Ok(h) = clean.parse::<u64>() {
d.power_on_hours.get_or_insert(h);
}
}
// ── ATA attribute table: " ID NAME flag val worst thresh ... RAW" ─
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() >= 10 {
if let Ok(id) = parts[0].parse::<u32>() {
// Raw value may be "31" or "31 (Min/Max 25/40)" — take first token
let raw: u64 = parts[9]
.parse()
.unwrap_or(0);
match id {
5 => { d.reallocated.get_or_insert(raw); }
9 => { d.power_on_hours.get_or_insert(raw); }
190 | 194 => { d.temperature.get_or_insert(raw as i32); }
197 => { d.pending.get_or_insert(raw); }
198 => { d.uncorrectable.get_or_insert(raw); }
_ => {}
}
}
}
// ── SCSI / SAS temperature line: "Current Drive Temperature: 31 C" ─
if let Some(v) = line.strip_prefix("Current Drive Temperature:") {
if let Some(t) = first_number(v) {
d.temperature.get_or_insert(t);
}
}
if let Some(v) = line.strip_prefix("Elements in grown defect list:") {
if let Ok(n) = v.trim().parse::<u64>() {
d.reallocated.get_or_insert(n);
}
}
if let Some(v) = line.strip_prefix("Accumulated power on time") {
// "Accumulated power on time, hours:minutes 12345:00" or similar
if let Some(h) = first_number(v) {
d.power_on_hours.get_or_insert(h as u64);
}
}
}
d
}
fn strip_any<'a>(line: &'a str, prefixes: &[&str]) -> Option<&'a str> {
for p in prefixes {
if let Some(rest) = line.strip_prefix(p) {
return Some(rest);
}
}
None
}
fn first_number(s: &str) -> Option<i32> {
s.split_whitespace()
.find_map(|tok| tok.parse::<i32>().ok())
}

656
src/ui.rs Normal file
View File

@@ -0,0 +1,656 @@
use ratatui::{
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
symbols,
text::{Line, Span, Text},
widgets::{
Axis, Block, Borders, Cell, Chart, Clear, Dataset, GraphType, List, ListItem, ListState,
Paragraph, Row, Table, Wrap,
},
Frame,
};
use crate::app::{
App, DriveType, HwmonSensor, MetricKind, NetIfaceDisplay, PasswordPopup, PoolDisplay,
ProcDisplay, SmartDrive, METRICS, NETWORK_IDX, POOL_IO_IDX, SMART_IDX,
};
use crate::netio::IfaceKind;
// ── Top-level draw ────────────────────────────────────────────────────────────
pub fn draw(f: &mut Frame, app: &App) {
let size = f.area();
let outer = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(0), Constraint::Length(1)])
.split(size);
let main = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(26), Constraint::Percentage(74)])
.split(outer[0]);
draw_left_panel(f, app, main[0]);
draw_right_panel(f, app, main[1]);
draw_status_bar(f, app, outer[1]);
// Overlay the password popup on top of everything else
if let Some(popup) = &app.popup {
draw_password_popup(f, popup, size);
}
}
// ── Left panel ────────────────────────────────────────────────────────────────
fn draw_left_panel(f: &mut Frame, app: &App, area: Rect) {
let mut items: Vec<ListItem> = METRICS
.iter()
.enumerate()
.map(|(i, m)| {
let val = app.current_values[i];
let text = Text::from(vec![
Line::from(Span::styled(m.name, Style::default().add_modifier(Modifier::BOLD))),
Line::from(Span::styled(
format!(" {}", format_value(m.kind, val)),
Style::default().fg(Color::Yellow),
)),
]);
ListItem::new(text)
})
.collect();
// Pool I/O item
let pool_summary = if app.pools.is_empty() {
" No pools detected".to_string()
} else {
let tr: f64 = app.pools.iter().map(|p| p.read_mb_s).sum();
let tw: f64 = app.pools.iter().map(|p| p.write_mb_s).sum();
format!(" {} pool{} R:{:.1} W:{:.1} MB/s", app.pools.len(), if app.pools.len() == 1 { "" } else { "s" }, tr, tw)
};
items.push(left_item("Pool I/O & Processes", &pool_summary, Color::Magenta));
// Network I/O item
let net_summary = {
let rx: f64 = app.net_ifaces.iter()
.filter(|i| !matches!(i.kind, IfaceKind::Container | IfaceKind::Loopback))
.map(|i| i.rx_mb_s).sum();
let tx: f64 = app.net_ifaces.iter()
.filter(|i| !matches!(i.kind, IfaceKind::Container | IfaceKind::Loopback))
.map(|i| i.tx_mb_s).sum();
format!("{:.2}{:.2} MB/s", rx, tx)
};
items.push(left_item("Network I/O", &net_summary, Color::Green));
// Disk SMART item
let smart_summary = if app.smart_permission_error {
" Run as root for SMART".to_string()
} else if app.hwmon.is_empty() && app.smart_drives.is_empty() {
" Collecting…".to_string()
} else {
let max_temp = app.hwmon.iter().map(|s| s.temp_c).chain(
app.smart_drives.iter().filter_map(|r| r.as_ref().ok()?.temperature)
).max();
max_temp.map(|t| format!(" Max temp: {}°C", t)).unwrap_or_else(|| " Temps available".into())
};
items.push(left_item("Disk SMART & Temps", &smart_summary, Color::Yellow));
let mut state = ListState::default();
state.select(Some(app.selected));
let list = List::new(items)
.block(
Block::default()
.title(" ZFS Stats ")
.title_style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD))
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Cyan)),
)
.highlight_style(Style::default().bg(Color::Blue).fg(Color::White).add_modifier(Modifier::BOLD))
.highlight_symbol("");
f.render_stateful_widget(list, area, &mut state);
}
fn left_item(title: &str, subtitle: &str, color: Color) -> ListItem<'static> {
ListItem::new(Text::from(vec![
Line::from(Span::styled(title.to_string(), Style::default().fg(color).add_modifier(Modifier::BOLD))),
Line::from(Span::styled(subtitle.to_string(), Style::default().fg(Color::Yellow))),
]))
}
// ── Right panel dispatcher ────────────────────────────────────────────────────
fn draw_right_panel(f: &mut Frame, app: &App, area: Rect) {
match app.selected {
POOL_IO_IDX => draw_pool_panel(f, app, area),
NETWORK_IDX => draw_network_panel(f, app, area),
SMART_IDX => draw_smart_panel(f, app, area),
_ => draw_metric_panel(f, app, area),
}
}
// ── ARC metric panel ──────────────────────────────────────────────────────────
fn draw_metric_panel(f: &mut Frame, app: &App, area: Rect) {
let right = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(10), Constraint::Min(0)])
.split(area);
draw_info(f, app, right[0]);
draw_arc_chart(f, app, right[1]);
}
fn draw_info(f: &mut Frame, app: &App, area: Rect) {
let metric = &METRICS[app.selected];
let val = app.current_values[app.selected];
let (min, max, avg) = app.history_stats();
let title = if app.error.is_some() { " Error ".to_string() } else { format!(" {} ", metric.name) };
let mut lines: Vec<Line> = vec![Line::from("")];
lines.push(Line::from(vec![
Span::styled(" Current ", Style::default().fg(Color::Gray)),
Span::styled(format_value(metric.kind, val), Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)),
Span::raw(" "),
Span::styled("Min ", Style::default().fg(Color::Gray)),
Span::styled(format_value(metric.kind, min), Style::default().fg(Color::Cyan)),
Span::raw(" "),
Span::styled("Max ", Style::default().fg(Color::Gray)),
Span::styled(format_value(metric.kind, max), Style::default().fg(Color::Magenta)),
Span::raw(" "),
Span::styled("Avg ", Style::default().fg(Color::Gray)),
Span::styled(format_value(metric.kind, avg), Style::default().fg(Color::Yellow)),
]));
lines.push(Line::from(""));
for desc_line in metric.description.lines() {
lines.push(Line::from(vec![Span::raw(" "), Span::styled(desc_line.to_string(), Style::default().fg(Color::White))]));
}
if let Some(ref e) = app.error {
lines.push(Line::from(Span::styled(format!(" Error: {}", e), Style::default().fg(Color::Red))));
}
f.render_widget(
Paragraph::new(Text::from(lines))
.block(Block::default().title(title).title_style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)).borders(Borders::ALL).border_style(Style::default().fg(Color::Cyan)))
.wrap(Wrap { trim: false }),
area,
);
}
fn draw_arc_chart(f: &mut Frame, app: &App, area: Rect) {
let history = &app.history[app.selected];
let metric = &METRICS[app.selected];
if history.len() < 2 {
f.render_widget(
Paragraph::new("Collecting data…")
.block(Block::default().title(format!(" {} — History ", metric.name)).title_style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)).borders(Borders::ALL).border_style(Style::default().fg(Color::Cyan)))
.alignment(Alignment::Center),
area,
);
return;
}
let data: Vec<(f64, f64)> = history.iter().enumerate().map(|(i, &v)| (i as f64, v)).collect();
render_line_chart(f, area, &[(&data, Color::Cyan, metric.name)], metric.unit, &format!(" {} — Last {}s ", metric.name, data.len()), Color::Cyan, |v| format_value(metric.kind, v));
}
// ── Pool I/O panel ────────────────────────────────────────────────────────────
fn draw_pool_panel(f: &mut Frame, app: &App, area: Rect) {
let pool_rows = (app.pools.len() + 3).max(5).min(12) as u16;
let split = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(pool_rows), Constraint::Min(0)])
.split(area);
draw_pool_table(f, app, split[0]);
draw_proc_table(f, app, split[1]);
}
fn draw_pool_table(f: &mut Frame, app: &App, area: Rect) {
let header = styled_header(&["Pool", "Health", "Used", "Size", "Use%", "Read MB/s", "Write MB/s", "R IOPS", "W IOPS"]);
let rows: Vec<Row> = app.pools.iter().map(pool_row).collect();
let widths = [Constraint::Length(14), Constraint::Length(8), Constraint::Length(10),
Constraint::Length(10), Constraint::Length(6), Constraint::Length(10),
Constraint::Length(11), Constraint::Length(8), Constraint::Length(8)];
let rows_or_placeholder = if rows.is_empty() {
vec![Row::new(vec![Cell::from("No pools found")])]
} else {
rows
};
f.render_widget(
Table::new(rows_or_placeholder, widths).header(header)
.block(Block::default().title(" Pool I/O ").title_style(Style::default().fg(Color::Magenta).add_modifier(Modifier::BOLD)).borders(Borders::ALL).border_style(Style::default().fg(Color::Magenta))),
area,
);
}
fn pool_row(p: &PoolDisplay) -> Row<'static> {
let use_pct = if p.size_bytes > 0 { p.alloc_bytes as f64 / p.size_bytes as f64 * 100.0 } else { 0.0 };
let health_style = match p.health.as_str() {
"ONLINE" => Style::default().fg(Color::Green),
"DEGRADED" => Style::default().fg(Color::Yellow),
"FAULTED" | "UNAVAIL" => Style::default().fg(Color::Red),
_ => Style::default().fg(Color::Gray),
};
let pct_style = if use_pct > 90.0 { Style::default().fg(Color::Red) } else if use_pct > 75.0 { Style::default().fg(Color::Yellow) } else { Style::default().fg(Color::Green) };
Row::new(vec![
Cell::from(p.name.clone()),
Cell::from(p.health.clone()).style(health_style),
Cell::from(fmt_bytes(p.alloc_bytes)),
Cell::from(fmt_bytes(p.size_bytes)),
Cell::from(format!("{:.1}%", use_pct)).style(pct_style),
Cell::from(format!("{:.2}", p.read_mb_s)).style(Style::default().fg(Color::Cyan)),
Cell::from(format!("{:.2}", p.write_mb_s)).style(Style::default().fg(Color::Yellow)),
Cell::from(format!("{:.0}", p.read_iops)),
Cell::from(format!("{:.0}", p.write_iops)),
])
}
fn draw_proc_table(f: &mut Frame, app: &App, area: Rect) {
let header = styled_header(&["PID", "Process", "Read MB/s", "Write MB/s", "Total MB/s"]);
let rows: Vec<Row> = app.top_procs.iter().map(proc_row).collect();
let widths = [Constraint::Length(8), Constraint::Min(16), Constraint::Length(10), Constraint::Length(11), Constraint::Length(11)];
let rows_or_placeholder = if rows.is_empty() { vec![Row::new(vec![Cell::from("No active I/O detected")])] } else { rows };
f.render_widget(
Table::new(rows_or_placeholder, widths).header(header)
.block(Block::default().title(" Top I/O Processes ").title_style(Style::default().fg(Color::Magenta).add_modifier(Modifier::BOLD)).borders(Borders::ALL).border_style(Style::default().fg(Color::Magenta))),
area,
);
}
fn proc_row(p: &ProcDisplay) -> Row<'static> {
let total = p.read_mb_s + p.write_mb_s;
let style = if total > 100.0 { Style::default().fg(Color::Red).add_modifier(Modifier::BOLD) }
else if total > 10.0 { Style::default().fg(Color::Yellow) }
else { Style::default().fg(Color::White) };
Row::new(vec![
Cell::from(p.pid.to_string()).style(Style::default().fg(Color::Gray)),
Cell::from(p.name.clone()),
Cell::from(format!("{:.2}", p.read_mb_s)).style(Style::default().fg(Color::Cyan)),
Cell::from(format!("{:.2}", p.write_mb_s)).style(Style::default().fg(Color::Yellow)),
Cell::from(format!("{:.2}", total)).style(style),
])
}
// ── Network I/O panel ─────────────────────────────────────────────────────────
fn draw_network_panel(f: &mut Frame, app: &App, area: Rect) {
// Table height: header + rows + borders, capped at 18 lines
let iface_count = app.net_ifaces.len().max(1);
let table_h = (iface_count + 3).min(18) as u16;
let split = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(table_h), Constraint::Min(0)])
.split(area);
draw_net_table(f, app, split[0]);
draw_net_chart(f, app, split[1]);
}
fn draw_net_table(f: &mut Frame, app: &App, area: Rect) {
let header = styled_header(&["Interface", "Type", "RX MB/s", "TX MB/s", "RX pps", "TX pps", "Errors"]);
let rows: Vec<Row> = app.net_ifaces.iter().map(net_iface_row).collect();
let widths = [Constraint::Length(16), Constraint::Length(10), Constraint::Length(10),
Constraint::Length(10), Constraint::Length(9), Constraint::Length(9), Constraint::Length(8)];
let rows_or_placeholder = if rows.is_empty() { vec![Row::new(vec![Cell::from("No interfaces")])] } else { rows };
f.render_widget(
Table::new(rows_or_placeholder, widths).header(header)
.block(Block::default().title(" Network Interfaces ").title_style(Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)).borders(Borders::ALL).border_style(Style::default().fg(Color::Green))),
area,
);
}
fn net_iface_row(i: &NetIfaceDisplay) -> Row<'static> {
let (kind_label, kind_color) = match i.kind {
IfaceKind::Physical => ("Physical", Color::Green),
IfaceKind::Vpn => ("VPN", Color::Cyan),
IfaceKind::Virtual => ("Virtual", Color::Blue),
IfaceKind::Container => ("Container", Color::DarkGray),
IfaceKind::Other => ("Other", Color::Gray),
IfaceKind::Loopback => ("Loopback", Color::DarkGray),
};
let err_total = i.rx_errors + i.tx_errors;
let err_style = if err_total > 0 { Style::default().fg(Color::Red) } else { Style::default().fg(Color::DarkGray) };
Row::new(vec![
Cell::from(i.name.clone()).style(Style::default().add_modifier(Modifier::BOLD)),
Cell::from(kind_label).style(Style::default().fg(kind_color)),
Cell::from(format!("{:.2}", i.rx_mb_s)).style(Style::default().fg(Color::Cyan)),
Cell::from(format!("{:.2}", i.tx_mb_s)).style(Style::default().fg(Color::Yellow)),
Cell::from(format!("{:.0}", i.rx_pps)),
Cell::from(format!("{:.0}", i.tx_pps)),
Cell::from(format!("{}", err_total)).style(err_style),
])
}
fn draw_net_chart(f: &mut Frame, app: &App, area: Rect) {
let rx = &app.net_rx_history;
let tx = &app.net_tx_history;
if rx.len() < 2 {
f.render_widget(
Paragraph::new("Collecting data…")
.block(Block::default().title(" Network RX / TX — History ").title_style(Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)).borders(Borders::ALL).border_style(Style::default().fg(Color::Green)))
.alignment(Alignment::Center),
area,
);
return;
}
let rx_data: Vec<(f64, f64)> = rx.iter().enumerate().map(|(i, &v)| (i as f64, v)).collect();
let tx_data: Vec<(f64, f64)> = tx.iter().enumerate().map(|(i, &v)| (i as f64, v)).collect();
render_line_chart(
f, area,
&[(&rx_data, Color::Cyan, "RX"), (&tx_data, Color::Yellow, "TX")],
"MB/s",
&format!(" Network RX/TX — Last {}s ", rx_data.len()),
Color::Green,
|v| {
if v >= 1024.0 { format!("{:.1}GB/s", v / 1024.0) }
else { format!("{:.1}MB/s", v) }
},
);
}
// ── SMART / hwmon panel ───────────────────────────────────────────────────────
fn draw_smart_panel(f: &mut Frame, app: &App, area: Rect) {
// Top: hwmon temps | Bottom: SMART drives
let hwmon_h = (app.hwmon.len() + 3).max(5).min(14) as u16;
let split = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(hwmon_h), Constraint::Min(0)])
.split(area);
draw_hwmon_table(f, app, split[0]);
draw_smart_table(f, app, split[1]);
}
fn draw_hwmon_table(f: &mut Frame, app: &App, area: Rect) {
let header = styled_header(&["Source", "Sensor", "Temp °C", "Critical"]);
let rows: Vec<Row> = app.hwmon.iter().map(hwmon_row).collect();
let widths = [Constraint::Length(14), Constraint::Min(20), Constraint::Length(9), Constraint::Length(9)];
let rows_or_placeholder = if rows.is_empty() { vec![Row::new(vec![Cell::from("No hwmon sensors found")])] } else { rows };
f.render_widget(
Table::new(rows_or_placeholder, widths).header(header)
.block(Block::default().title(" System Temperatures (hwmon) ").title_style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)).borders(Borders::ALL).border_style(Style::default().fg(Color::Yellow))),
area,
);
}
fn hwmon_row(s: &HwmonSensor) -> Row<'static> {
let temp_style = temp_color(s.temp_c, s.crit_c);
let crit_str = s.crit_c.map(|c| format!("{}°C", c)).unwrap_or_else(|| "".into());
Row::new(vec![
Cell::from(s.source.clone()).style(Style::default().fg(Color::Gray)),
Cell::from(s.label.clone()),
Cell::from(format!("{}°C", s.temp_c)).style(temp_style),
Cell::from(crit_str).style(Style::default().fg(Color::DarkGray)),
])
}
fn draw_smart_table(f: &mut Frame, app: &App, area: Rect) {
let title = if app.smart_permission_error {
" Drive SMART [run as root for drive data] "
} else {
" Drive SMART "
};
let header = styled_header(&["Device", "Type", "Model", "Health", "Temp", "Power-On h", "Realloc", "Pending"]);
let widths = [Constraint::Length(10), Constraint::Length(6), Constraint::Min(18),
Constraint::Length(8), Constraint::Length(7), Constraint::Length(11),
Constraint::Length(8), Constraint::Length(8)];
let rows: Vec<Row> = app.smart_drives.iter().map(|r| match r {
Ok(d) => smart_drive_row(d),
Err(e) => Row::new(vec![Cell::from("?"), Cell::from(""), Cell::from(""), Cell::from(""),
Cell::from(""), Cell::from(""), Cell::from(""),
Cell::from(e.clone()).style(Style::default().fg(Color::Red))]),
}).collect();
let rows_or_placeholder = if rows.is_empty() {
vec![Row::new(vec![
Cell::from(if app.smart_permission_error {
"Run as root (sudo zfs-stats) to read SMART data"
} else {
"No drives detected — install smartmontools"
}),
])]
} else {
rows
};
f.render_widget(
Table::new(rows_or_placeholder, widths).header(header)
.block(Block::default().title(title).title_style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)).borders(Borders::ALL).border_style(Style::default().fg(Color::Yellow))),
area,
);
}
fn smart_drive_row(d: &SmartDrive) -> Row<'static> {
let type_label = match d.drive_type {
DriveType::Ata => "SATA",
DriveType::Nvme => "NVMe",
DriveType::Scsi => "SAS",
DriveType::Unknown => "?",
};
let health_style = match d.health.as_str() {
"PASSED" => Style::default().fg(Color::Green),
"FAILED" => Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
_ => Style::default().fg(Color::Gray),
};
let temp_cell = match d.temperature {
Some(t) => Cell::from(format!("{}°C", t)).style(temp_color(t, None)),
None => Cell::from("").style(Style::default().fg(Color::DarkGray)),
};
let realloc_style = |n: Option<u64>| match n {
Some(0) | None => Style::default().fg(Color::DarkGray),
_ => Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
};
Row::new(vec![
Cell::from(d.device.clone()),
Cell::from(type_label),
Cell::from(d.model.clone()),
Cell::from(d.health.clone()).style(health_style),
temp_cell,
Cell::from(d.power_on_hours.map(|h| format!("{}", h)).unwrap_or_else(|| "".into())),
Cell::from(d.reallocated.map(|n| format!("{}", n)).unwrap_or_else(|| "".into()))
.style(realloc_style(d.reallocated)),
Cell::from(d.pending.map(|n| format!("{}", n)).unwrap_or_else(|| "".into()))
.style(realloc_style(d.pending)),
])
}
// ── Password popup ────────────────────────────────────────────────────────────
fn draw_password_popup(f: &mut Frame, popup: &PasswordPopup, area: Rect) {
let popup_area = centered_rect(54, 9, area);
// Clear the region behind the popup so it stands out
f.render_widget(Clear, popup_area);
let stars: String = "".repeat(popup.input.len());
// Blinking block cursor effect
let cursor = Span::styled("", Style::default().fg(Color::Yellow));
let mut lines: Vec<Line> = vec![
Line::from(""),
Line::from(vec![
Span::styled(" Password: ", Style::default().fg(Color::Gray)),
Span::styled(stars, Style::default().fg(Color::White).add_modifier(Modifier::BOLD)),
cursor,
]),
Line::from(""),
];
if let Some(ref err) = popup.error {
lines.push(Line::from(Span::styled(
format!("{}", err),
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
)));
} else {
lines.push(Line::from(Span::styled(
" SMART data requires elevated privileges",
Style::default().fg(Color::DarkGray),
)));
}
lines.push(Line::from(""));
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled("[Enter]", Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)),
Span::raw(" Confirm "),
Span::styled("[Esc]", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
Span::raw(" Cancel"),
]));
f.render_widget(
Paragraph::new(Text::from(lines))
.block(
Block::default()
.title(" 🔒 sudo password for SMART ")
.title_style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD))
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Yellow)),
)
.wrap(Wrap { trim: false }),
popup_area,
);
}
fn centered_rect(width: u16, height: u16, area: Rect) -> Rect {
Rect {
x: area.x + area.width.saturating_sub(width) / 2,
y: area.y + area.height.saturating_sub(height) / 2,
width: width.min(area.width),
height: height.min(area.height),
}
}
// ── Status bar ────────────────────────────────────────────────────────────────
fn draw_status_bar(f: &mut Frame, app: &App, area: Rect) {
let msg = if app.selected == SMART_IDX && app.smart_permission_error {
" ZFS Stats [!] SMART needs root — press p to enter sudo password q Quit"
} else if app.error.is_some() || app.pool_error.is_some() {
" ZFS Stats [!] Some stats unavailable ↑/↓ or j/k Navigate q Quit"
} else {
" ZFS Stats ↑/↓ or j/k Navigate q Quit"
};
f.render_widget(Paragraph::new(msg).style(Style::default().bg(Color::DarkGray).fg(Color::White)), area);
}
// ── Shared chart renderer ─────────────────────────────────────────────────────
/// Render one or more line series on a single Chart widget.
fn render_line_chart<F>(
f: &mut Frame,
area: Rect,
series: &[(&[(f64, f64)], Color, &str)],
y_unit: &str,
title: &str,
border_color: Color,
fmt_y: F,
) where
F: Fn(f64) -> String,
{
// Compute Y bounds across all series
let y_max = series.iter().flat_map(|(d, _, _)| d.iter().map(|(_, v)| *v)).fold(f64::MIN, f64::max);
let y_min = series.iter().flat_map(|(d, _, _)| d.iter().map(|(_, v)| *v)).fold(f64::MAX, f64::min);
let y_range = (y_max - y_min).max(0.001);
let y_top = y_max + y_range * 0.1;
let y_bot = (y_min - y_range * 0.1).max(0.0);
let y_mid = (y_top + y_bot) / 2.0;
let x_max = series.iter().map(|(d, _, _)| d.len()).max().unwrap_or(1) as f64 - 1.0;
let x_mid = (x_max / 2.0) as usize;
let datasets: Vec<Dataset> = series
.iter()
.map(|(data, color, name)| {
Dataset::default()
.name(*name)
.marker(symbols::Marker::Braille)
.graph_type(GraphType::Line)
.style(Style::default().fg(*color))
.data(data)
})
.collect();
let chart = Chart::new(datasets)
.block(
Block::default()
.title(title)
.title_style(Style::default().fg(border_color).add_modifier(Modifier::BOLD))
.borders(Borders::ALL)
.border_style(Style::default().fg(border_color)),
)
.x_axis(
Axis::default()
.title(Span::styled("Time (s)", Style::default().fg(Color::Gray)))
.style(Style::default().fg(Color::DarkGray))
.bounds([0.0, x_max.max(1.0)])
.labels(vec![
Span::raw("0"),
Span::raw(format!("{}s", x_mid)),
Span::raw(format!("{}s", x_max as usize)),
]),
)
.y_axis(
Axis::default()
.title(Span::styled(y_unit, Style::default().fg(Color::Gray)))
.style(Style::default().fg(Color::DarkGray))
.bounds([y_bot, y_top])
.labels(vec![
Span::raw(fmt_y(y_bot)),
Span::raw(fmt_y(y_mid)),
Span::raw(fmt_y(y_top)),
]),
);
f.render_widget(chart, area);
}
// ── Formatting helpers ────────────────────────────────────────────────────────
pub fn format_value(kind: MetricKind, val: f64) -> String {
match kind {
MetricKind::ArcHitRatio | MetricKind::L2HitRatio | MetricKind::ArcL2CacheUsage => format!("{:.2}%", val),
MetricKind::ArcL2Rate => if val >= 1024.0 { format!("{:.2} GB/s", val / 1024.0) } else { format!("{:.2} MB/s", val) },
MetricKind::ArcSize | MetricKind::ArcL2Size => {
if val >= 1024.0 { format!("{:.2} GB", val / 1024.0) } else { format!("{:.1} MB", val) }
}
}
}
fn fmt_bytes(b: u64) -> String {
const TB: u64 = 1_099_511_627_776;
const GB: u64 = 1_073_741_824;
const MB: u64 = 1_048_576;
const KB: u64 = 1_024;
if b >= TB { format!("{:.2} TB", b as f64 / TB as f64) }
else if b >= GB { format!("{:.2} GB", b as f64 / GB as f64) }
else if b >= MB { format!("{:.1} MB", b as f64 / MB as f64) }
else if b >= KB { format!("{:.1} KB", b as f64 / KB as f64) }
else { format!("{} B", b) }
}
fn temp_color(temp_c: i32, crit_c: Option<i32>) -> Style {
let crit = crit_c.unwrap_or(90);
if temp_c >= crit { Style::default().fg(Color::Red).add_modifier(Modifier::BOLD) }
else if temp_c >= crit - 20 { Style::default().fg(Color::Yellow) }
else { Style::default().fg(Color::Green) }
}
fn styled_header(labels: &[&str]) -> Row<'static> {
Row::new(labels.iter().map(|l| {
Cell::from(l.to_string()).style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD))
}).collect::<Vec<_>>())
.height(1)
.style(Style::default().add_modifier(Modifier::UNDERLINED))
}

44
src/zfs.rs Normal file
View File

@@ -0,0 +1,44 @@
use std::collections::HashMap;
use std::fs;
#[derive(Debug, Clone, Default)]
pub struct ArcStats {
pub hits: u64,
pub misses: u64,
pub l2_hits: u64,
pub l2_misses: u64,
pub size: u64,
pub l2_size: u64,
pub l2_read_bytes: u64,
}
pub fn read_arcstats() -> anyhow::Result<ArcStats> {
let content = fs::read_to_string("/proc/spl/kstat/zfs/arcstats")?;
let mut map = HashMap::new();
// File format: header line, type-line, then "name type value" rows
for line in content.lines().skip(2) {
let mut parts = line.split_whitespace();
let name = match parts.next() {
Some(n) => n,
None => continue,
};
// skip the type column
parts.next();
if let Some(val_str) = parts.next() {
if let Ok(val) = val_str.parse::<u64>() {
map.insert(name.to_string(), val);
}
}
}
Ok(ArcStats {
hits: map.get("hits").copied().unwrap_or(0),
misses: map.get("misses").copied().unwrap_or(0),
l2_hits: map.get("l2_hits").copied().unwrap_or(0),
l2_misses: map.get("l2_misses").copied().unwrap_or(0),
size: map.get("size").copied().unwrap_or(0),
l2_size: map.get("l2_size").copied().unwrap_or(0),
l2_read_bytes: map.get("l2_read_bytes").copied().unwrap_or(0),
})
}