first commit
This commit is contained in:
1736
Cargo.lock
generated
Normal file
1736
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
9
Cargo.toml
Normal file
9
Cargo.toml
Normal 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
515
src/app.rs
Normal 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
93
src/main.rs
Normal 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
94
src/netio.rs
Normal 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
100
src/pools.rs
Normal 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
49
src/procs.rs
Normal 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
304
src/smart.rs
Normal 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
656
src/ui.rs
Normal 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
44
src/zfs.rs
Normal 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),
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user