diff --git a/Cargo.lock b/Cargo.lock index caf1966..9bda807 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -200,6 +200,15 @@ dependencies = [ "strsim", ] +[[package]] +name = "clap_complete" +version = "4.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037e2a1a92236d0aff7e845093f64661d6df4c02c9fcc61a60e9e1d736fa392f" +dependencies = [ + "clap", +] + [[package]] name = "clap_derive" version = "4.6.4" @@ -218,6 +227,16 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +[[package]] +name = "clap_mangen" +version = "0.2.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e30ffc187e2e3aeafcd1c6e2aa416e29739454c0ccaa419226d5ecd181f2d78" +dependencies = [ + "clap", + "roff", +] + [[package]] name = "colorchoice" version = "1.0.5" @@ -1204,14 +1223,17 @@ dependencies = [ [[package]] name = "ratfetch" -version = "0.1.0" +version = "0.2.0" dependencies = [ "clap", + "clap_complete", + "clap_mangen", "crossterm", "dirs", "libc", "ratatui", "serde", + "serde_json", "toml", ] @@ -1264,6 +1286,12 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "roff" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "323c417e1d9665a65b263ec744ba09030cfb277e9daa0b018a4ab62e57bc8189" + [[package]] name = "rustc_version" version = "0.4.1" @@ -1340,6 +1368,19 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + [[package]] name = "serde_spanned" version = "1.1.1" @@ -1905,3 +1946,9 @@ name = "wit-bindgen" version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml index 5a5aee0..1a78714 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ratfetch" -version = "0.1.0" +version = "0.2.0" edition = "2021" # Set by ratatui 0.30, which is the highest MSRV in the dependency tree. rust-version = "1.88" @@ -17,11 +17,14 @@ include = ["src/**", "assets/**", "build.rs", "README.md", "LICENSE"] [dependencies] clap = { version = "4.6", features = ["derive"] } +clap_complete = "4.6" +clap_mangen = "0.2" crossterm = "0.29" dirs = "6.0" libc = "0.2" ratatui = "0.30" serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" toml = "1.1" [build-dependencies] diff --git a/README.md b/README.md index 34ef9cf..5defef3 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ process table, filesystems and interfaces, all refreshing while you watch. ## What it does - **Live, not a snapshot.** Everything re-reads on a configurable interval. -- **526 logos**, the full set vendored from fastfetch, with their original +- **527 logos**, the full set vendored from fastfetch, with their original colour palettes. Auto-detected from `/etc/os-release`, or pick your own. - **Four tabs** — overview, processes, filesystems, interfaces. - **13 built-in themes**, plus per-role colour overrides. The default is @@ -75,6 +75,11 @@ ratfetch --once # one frame, like a classic fetch tool ratfetch --logo nixos # override the detected logo ratfetch --theme gruvbox-dark ratfetch --interval 250 # four samples a second +ratfetch --format json # one machine-readable snapshot +ratfetch --format plain # clean line-oriented output +ratfetch --diagnose # explain unavailable collectors +ratfetch --generate-completion fish +ratfetch --generate-man ratfetch.1 ratfetch --list-logos | fzf # browse what is bundled ``` @@ -90,9 +95,17 @@ ratfetch --list-logos | fzf # browse what is bundled | `c` | per-core CPU meters | | `space` | freeze / resume | | `r` | reload the config | +| `/` / `ctrl+u` | filter / clear the process list | +| `enter` / `t` | process details / tree view | +| `x` | confirm and send SIGTERM to the selected process | +| `i` / `e` | full system information / error history | All of them are rebindable. +The process table keeps a visible selection and scrollbar. Mouse clicks switch +tabs, and short split panes automatically prioritize information and meters +over decorative art and graphs. + ## Configuration ```sh diff --git a/assets/config.template.toml b/assets/config.template.toml index a052e79..a8526f4 100644 --- a/assets/config.template.toml +++ b/assets/config.template.toml @@ -34,7 +34,7 @@ watch_config = true [logo] # "auto" detects the distro. Otherwise a logo name ("arch", "nixos", "debian"), # "none", or "file:/path/to/your/art.txt". Run `ratfetch --list-logos` to see -# all 526 of them. +# all 527 of them. source = "auto" # Prefer the compact variant when the logo ships one. small = false @@ -119,7 +119,8 @@ colors = 3 # Rows of the info table, in order. Available rows: # title, separator, blank, os, host, kernel, uptime, packages, shell, # terminal, de, wm, resolution, cpu, cpu_temp, gpu, memory, swap, disk, -# local_ip, battery, load_avg, processes, users, locale, date_time, colors +# local_ip, local_ipv6, battery, load_avg, processes, users, locale, date_time, +# colors items = [ "title", "separator", @@ -154,7 +155,7 @@ bold_title = true hide_empty = true [meters] -# Live bars. Available: cpu, memory, swap, disk, battery, load. +# Live bars. Available: cpu, memory, swap, disk, battery, load, gpu. items = ["cpu", "memory", "swap", "disk"] # bar, line, blocks, or dots. style = "bar" @@ -169,7 +170,7 @@ label_width = 8 per_core = false [graphs] -# Time series. Available: cpu, memory, swap, network, disk, load. +# Time series. Available: cpu, memory, swap, network, disk, disk_io, load, gpu. items = ["cpu", "memory", "network"] # sparkline, braille, or bars. style = "sparkline" @@ -229,3 +230,10 @@ scroll_up = ["up", "k"] sort_next = ["s"] toggle_per_core = ["c"] freeze = ["space"] +info_details = ["i"] +errors = ["e"] +search_processes = ["/"] +clear_process_filter = ["ctrl+u"] +process_details = ["enter"] +process_tree = ["t"] +signal_process = ["x"] diff --git a/install.sh b/install.sh index 99a6239..d7a39c4 100755 --- a/install.sh +++ b/install.sh @@ -68,6 +68,7 @@ fi need tar need mktemp +need sha256sum # --- which version ---------------------------------------------------------- @@ -95,16 +96,13 @@ trap 'rm -rf "$tmp"' EXIT INT TERM fetch_to "$base/${name}.tar.gz" "$tmp/${name}.tar.gz" \ || die "no build published for ${target} in ${VERSION}." -if fetch_to "$base/SHA256SUMS" "$tmp/SHA256SUMS" 2>/dev/null; then - expected=$(grep " ${name}.tar.gz\$" "$tmp/SHA256SUMS" | cut -d' ' -f1 || true) - if [ -n "$expected" ] && command -v sha256sum >/dev/null 2>&1; then - actual=$(sha256sum "$tmp/${name}.tar.gz" | cut -d' ' -f1) - [ "$expected" = "$actual" ] || die "checksum mismatch for ${name}.tar.gz — refusing to install." - ok "checksum verified" - else - say "skipping checksum verification (no entry or no sha256sum)" - fi -fi +fetch_to "$base/SHA256SUMS" "$tmp/SHA256SUMS" 2>/dev/null \ + || die "could not download SHA256SUMS — refusing an unverified install." +expected=$(grep " ${name}.tar.gz\$" "$tmp/SHA256SUMS" | cut -d' ' -f1 || true) +[ -n "$expected" ] || die "SHA256SUMS has no entry for ${name}.tar.gz." +actual=$(sha256sum "$tmp/${name}.tar.gz" | cut -d' ' -f1) +[ "$expected" = "$actual" ] || die "checksum mismatch for ${name}.tar.gz — refusing to install." +ok "checksum verified" tar -xzf "$tmp/${name}.tar.gz" -C "$tmp" [ -x "$tmp/$name/ratfetch" ] || die "the archive did not contain a ratfetch binary." diff --git a/site/index.html b/site/index.html index f8a5626..9a91e4c 100644 --- a/site/index.html +++ b/site/index.html @@ -80,7 +80,7 @@
sysinfo crate. /proc and /sys are read directly.# a specific version, or a different directory
-RATFETCH_VERSION=v0.1.0 sh -c "$(curl -fsSL https://tuna4ll.github.io/ratfetch/install.sh)"
+RATFETCH_VERSION=v0.2.0 sh -c "$(curl -fsSL https://tuna4ll.github.io/ratfetch/install.sh)"
RATFETCH_INSTALL_DIR=~/bin sh -c "$(curl -fsSL https://tuna4ll.github.io/ratfetch/install.sh)"
# manual setup, from source — needs Rust 1.88+
diff --git a/src/app.rs b/src/app.rs
index fe021f8..259213b 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -4,7 +4,9 @@ use std::collections::VecDeque;
use std::path::PathBuf;
use std::time::{Duration, Instant, SystemTime};
-use crossterm::event::{KeyEvent, KeyEventKind, MouseEvent, MouseEventKind};
+use crossterm::event::{
+ KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseButton, MouseEvent, MouseEventKind,
+};
use crate::config::enums::{ProcSort, Tab};
use crate::config::theme::{self, Theme};
@@ -91,6 +93,9 @@ pub struct History {
pub memory: Ring,
pub swap: Ring,
pub disk: Ring,
+ pub disk_read: Ring,
+ pub disk_write: Ring,
+ pub gpu: Ring,
pub load: Ring,
pub net_rx: Ring,
pub net_tx: Ring,
@@ -103,18 +108,24 @@ impl History {
memory: Ring::new(capacity),
swap: Ring::new(capacity),
disk: Ring::new(capacity),
+ disk_read: Ring::new(capacity),
+ disk_write: Ring::new(capacity),
+ gpu: Ring::new(capacity),
load: Ring::new(capacity),
net_rx: Ring::new(capacity),
net_tx: Ring::new(capacity),
}
}
- fn each_mut(&mut self) -> [&mut Ring; 7] {
+ fn each_mut(&mut self) -> [&mut Ring; 10] {
[
&mut self.cpu,
&mut self.memory,
&mut self.swap,
&mut self.disk,
+ &mut self.disk_read,
+ &mut self.disk_write,
+ &mut self.gpu,
&mut self.load,
&mut self.net_rx,
&mut self.net_tx,
@@ -137,6 +148,15 @@ impl History {
.map(|p| p.percent())
.unwrap_or(0.0),
);
+ let (read, write) = crate::sys::disk_io::total_rate(&d.disk_io);
+ self.disk_read.push(read);
+ self.disk_write.push(write);
+ self.gpu.push(
+ d.gpus
+ .iter()
+ .find_map(|gpu| gpu.usage_percent)
+ .unwrap_or(0.0),
+ );
self.load.push(d.load[0]);
let (rx, tx) = d.net_rates();
self.net_rx.push(rx);
@@ -158,6 +178,16 @@ pub enum Action {
SortNext,
TogglePerCore,
ToggleFreeze,
+ ToggleInfoDetails,
+ ToggleErrors,
+ SearchProcesses,
+ ClearProcessFilter,
+ ToggleProcessDetails,
+ ToggleProcessTree,
+ SignalProcess,
+ ConfirmSignal,
+ CancelSignal,
+ SelectTab(Tab),
}
/// A transient message shown in the footer.
@@ -197,8 +227,16 @@ pub struct App {
pub per_core: bool,
pub frozen: bool,
pub show_help: bool,
+ pub show_info_details: bool,
+ pub show_errors: bool,
+ pub process_searching: bool,
+ pub process_query: String,
+ pub process_tree: bool,
+ pub show_process_details: bool,
+ pub show_signal_confirm: bool,
pub scroll: usize,
pub status: Option,
+ pub errors: VecDeque,
pub should_quit: bool,
pub started: Instant,
sampler: Sampler,
@@ -240,8 +278,16 @@ impl App {
logo,
frozen: false,
show_help: false,
+ show_info_details: false,
+ show_errors: false,
+ process_searching: false,
+ process_query: String::new(),
+ process_tree: false,
+ show_process_details: false,
+ show_signal_confirm: false,
scroll: 0,
status: None,
+ errors: VecDeque::with_capacity(32),
should_quit: false,
started: Instant::now(),
sampler: Sampler::new(),
@@ -277,8 +323,16 @@ impl App {
self.config.processes.ascending,
);
self.history.record(&self.dynamic, &self.config);
-
- if self.status.as_ref().is_some_and(Status::expired) {
+ let rows =
+ crate::sys::procs::view(&self.dynamic.procs, &self.process_query, self.process_tree)
+ .len();
+ self.scroll = self.scroll.min(rows.saturating_sub(1));
+
+ if self
+ .status
+ .as_ref()
+ .is_some_and(|status| !status.is_error && status.expired())
+ {
self.status = None;
}
if self.config.general.watch_config {
@@ -301,10 +355,28 @@ impl App {
let k = &self.config.keys;
let hit = |list: &[crate::config::KeyBinding]| list.iter().any(|b| b.matches(key));
+ if self.show_signal_confirm {
+ return match key.code {
+ KeyCode::Char('y') => Action::ConfirmSignal,
+ KeyCode::Char('n') => Action::CancelSignal,
+ _ if hit(&k.quit) => Action::CancelSignal,
+ _ => Action::None,
+ };
+ }
+ if self.show_process_details && (hit(&k.process_details) || hit(&k.quit)) {
+ return Action::ToggleProcessDetails;
+ }
+
// Help is modal: while it is open, any bound key closes it.
if self.show_help && (hit(&k.help) || hit(&k.quit)) {
return Action::ToggleHelp;
}
+ if self.show_info_details && (hit(&k.info_details) || hit(&k.quit)) {
+ return Action::ToggleInfoDetails;
+ }
+ if self.show_errors && (hit(&k.errors) || hit(&k.quit)) {
+ return Action::ToggleErrors;
+ }
if hit(&k.quit) {
Action::Quit
@@ -326,6 +398,20 @@ impl App {
Action::TogglePerCore
} else if hit(&k.freeze) {
Action::ToggleFreeze
+ } else if hit(&k.info_details) {
+ Action::ToggleInfoDetails
+ } else if hit(&k.errors) {
+ Action::ToggleErrors
+ } else if self.tab == Tab::Processes && hit(&k.search_processes) {
+ Action::SearchProcesses
+ } else if self.tab == Tab::Processes && hit(&k.clear_process_filter) {
+ Action::ClearProcessFilter
+ } else if self.tab == Tab::Processes && hit(&k.process_details) {
+ Action::ToggleProcessDetails
+ } else if self.tab == Tab::Processes && hit(&k.process_tree) {
+ Action::ToggleProcessTree
+ } else if self.tab == Tab::Processes && hit(&k.signal_process) {
+ Action::SignalProcess
} else {
Action::None
}
@@ -336,6 +422,24 @@ impl App {
match ev.kind {
MouseEventKind::ScrollDown => Action::ScrollDown,
MouseEventKind::ScrollUp => Action::ScrollUp,
+ MouseEventKind::Down(MouseButton::Left)
+ if self.config.general.tab_bar
+ && ev.row == self.config.layout.margin
+ && self.config.general.tabs.len() > 1 =>
+ {
+ let mut column = self.config.layout.margin.saturating_add(1);
+ for (index, tab) in self.config.general.tabs.iter().enumerate() {
+ if index > 0 {
+ column = column.saturating_add(3);
+ }
+ let end = column.saturating_add(tab.to_string().chars().count() as u16 + 2);
+ if ev.column >= column && ev.column < end {
+ return Action::SelectTab(*tab);
+ }
+ column = end;
+ }
+ Action::None
+ }
_ => Action::None,
}
}
@@ -349,7 +453,21 @@ impl App {
Action::Reload => self.reload(),
Action::NextTab => self.switch_tab(1),
Action::PrevTab => self.switch_tab(-1),
- Action::ScrollDown => self.scroll = self.scroll.saturating_add(1),
+ Action::ScrollDown => {
+ let max = match self.tab {
+ Tab::Processes => crate::sys::procs::view(
+ &self.dynamic.procs,
+ &self.process_query,
+ self.process_tree,
+ )
+ .len()
+ .saturating_sub(1),
+ Tab::Disks => self.dynamic.disks.len().saturating_sub(1),
+ Tab::Network => self.dynamic.nets.len().saturating_sub(1),
+ Tab::Overview => 4095,
+ };
+ self.scroll = self.scroll.saturating_add(1).min(max);
+ }
Action::ScrollUp => self.scroll = self.scroll.saturating_sub(1),
Action::SortNext => {
self.sort = self.sort.next();
@@ -368,9 +486,110 @@ impl App {
let word = if self.frozen { "frozen" } else { "running" };
self.set_status(word, false);
}
+ Action::ToggleInfoDetails => {
+ self.show_info_details = !self.show_info_details;
+ }
+ Action::ToggleErrors => {
+ self.show_errors = !self.show_errors;
+ if !self.show_errors && self.status.as_ref().is_some_and(|s| s.is_error) {
+ self.status = None;
+ }
+ }
+ Action::SearchProcesses => {
+ self.process_searching = true;
+ self.scroll = 0;
+ }
+ Action::ClearProcessFilter => {
+ self.process_query.clear();
+ self.scroll = 0;
+ }
+ Action::ToggleProcessDetails => {
+ if self.selected_process().is_some() {
+ self.show_process_details = !self.show_process_details;
+ }
+ }
+ Action::ToggleProcessTree => {
+ self.process_tree = !self.process_tree;
+ self.scroll = 0;
+ self.set_status(
+ if self.process_tree {
+ "process tree"
+ } else {
+ "sorted process list"
+ },
+ false,
+ );
+ }
+ Action::SignalProcess => {
+ self.show_signal_confirm = self.selected_process().is_some();
+ }
+ Action::ConfirmSignal => {
+ let selected = self
+ .selected_process()
+ .map(|process| (process.pid, process.name.clone()));
+ self.show_signal_confirm = false;
+ if let Some((pid, name)) = selected {
+ // SAFETY: kill does not dereference pointers; it reports failure.
+ let result = unsafe { libc::kill(pid, libc::SIGTERM) };
+ if result == 0 {
+ self.set_status(format!("sent SIGTERM to {name} ({pid})"), false);
+ } else {
+ self.set_status(
+ format!(
+ "cannot signal {name} ({pid}): {}",
+ std::io::Error::last_os_error()
+ ),
+ true,
+ );
+ }
+ }
+ }
+ Action::CancelSignal => self.show_signal_confirm = false,
+ Action::SelectTab(tab) => {
+ self.tab = tab;
+ self.scroll = 0;
+ }
}
}
+ /// Handles editable process-search input before global key bindings.
+ pub fn handle_text_input(&mut self, key: &KeyEvent) -> bool {
+ if !self.process_searching || key.kind != KeyEventKind::Press {
+ return false;
+ }
+ match key.code {
+ KeyCode::Esc | KeyCode::Enter => self.process_searching = false,
+ KeyCode::Backspace => {
+ self.process_query.pop();
+ self.scroll = 0;
+ }
+ KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => {
+ self.process_query.clear();
+ self.scroll = 0;
+ }
+ KeyCode::Char(c)
+ if !key
+ .modifiers
+ .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) =>
+ {
+ self.process_query.push(c);
+ self.scroll = 0;
+ }
+ _ => {}
+ }
+ true
+ }
+
+ pub fn selected_process(&self) -> Option<&crate::sys::procs::Process> {
+ crate::sys::procs::view(&self.dynamic.procs, &self.process_query, self.process_tree)
+ .get(self.scroll)
+ .map(|(process, _)| *process)
+ }
+
+ pub fn config_sources(&self) -> &[PathBuf] {
+ &self.config_files
+ }
+
fn switch_tab(&mut self, delta: isize) {
let tabs = &self.config.general.tabs;
if tabs.is_empty() {
@@ -385,6 +604,13 @@ impl App {
/// Shows a message in the footer.
pub fn set_status(&mut self, text: impl Into, is_error: bool) {
+ let text = text.into();
+ if is_error {
+ if self.errors.len() == 32 {
+ self.errors.pop_front();
+ }
+ self.errors.push_back(text.clone());
+ }
self.status = Some(Status::new(text, is_error));
}
diff --git a/src/cli.rs b/src/cli.rs
index 6a81eb6..a7d8bc7 100644
--- a/src/cli.rs
+++ b/src/cli.rs
@@ -5,7 +5,7 @@ use std::path::PathBuf;
use std::process::ExitCode;
use std::time::Instant;
-use clap::Parser;
+use clap::{CommandFactory, Parser, ValueEnum};
use crossterm::event::{
self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEvent, KeyModifiers,
};
@@ -19,6 +19,40 @@ use ratatui::{Terminal, TerminalOptions, Viewport};
use crate::app::{Action, App};
use crate::config::{self, LoadOptions};
+#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, ValueEnum)]
+pub enum OutputFormat {
+ /// TUI on a terminal, plain text when redirected.
+ #[default]
+ Auto,
+ /// The interactive terminal interface, including ANSI control sequences.
+ Tui,
+ /// Stable, line-oriented text without terminal control sequences.
+ Plain,
+ /// A complete machine-readable snapshot.
+ Json,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
+pub enum CompletionShell {
+ Bash,
+ Zsh,
+ Fish,
+ Elvish,
+ Powershell,
+}
+
+impl From for clap_complete::Shell {
+ fn from(shell: CompletionShell) -> Self {
+ match shell {
+ CompletionShell::Bash => Self::Bash,
+ CompletionShell::Zsh => Self::Zsh,
+ CompletionShell::Fish => Self::Fish,
+ CompletionShell::Elvish => Self::Elvish,
+ CompletionShell::Powershell => Self::PowerShell,
+ }
+ }
+}
+
/// A live, always-updating system fetch for the terminal.
#[derive(Debug, Parser)]
#[command(name = "ratfetch", version, about, long_about = None)]
@@ -51,6 +85,10 @@ pub struct Args {
#[arg(short = '1', long)]
pub once: bool,
+ /// Output format. Non-terminal output defaults to plain.
+ #[arg(long, value_enum, default_value_t)]
+ pub format: OutputFormat,
+
/// Quit after this many seconds.
#[arg(long, value_name = "SECS")]
pub exit_after: Option,
@@ -78,6 +116,18 @@ pub struct Args {
/// List every built-in theme and exit.
#[arg(long)]
pub list_themes: bool,
+
+ /// Explain which collectors and kernel interfaces are available.
+ #[arg(long)]
+ pub diagnose: bool,
+
+ /// Generate a completion script and exit.
+ #[arg(long, value_enum, value_name = "SHELL")]
+ pub generate_completion: Option,
+
+ /// Generate a man page to stdout or an optional path and exit.
+ #[arg(long, value_name = "PATH", num_args = 0..=1, default_missing_value = "")]
+ pub generate_man: Option,
}
impl Args {
@@ -113,6 +163,11 @@ pub fn run() -> ExitCode {
match dispatch(&args) {
Ok(code) => code,
Err(e) => {
+ if e.downcast_ref::()
+ .is_some_and(|error| error.kind() == io::ErrorKind::BrokenPipe)
+ {
+ return ExitCode::SUCCESS;
+ }
eprintln!("ratfetch: {e}");
ExitCode::FAILURE
}
@@ -121,6 +176,28 @@ pub fn run() -> ExitCode {
fn dispatch(args: &Args) -> Result> {
// The informational modes never touch the terminal.
+ if let Some(shell) = args.generate_completion {
+ let mut command = Args::command();
+ clap_complete::generate(
+ clap_complete::Shell::from(shell),
+ &mut command,
+ "ratfetch",
+ &mut io::stdout().lock(),
+ );
+ return Ok(ExitCode::SUCCESS);
+ }
+
+ if let Some(path) = &args.generate_man {
+ let man = clap_mangen::Man::new(Args::command());
+ if path.is_empty() {
+ man.render(&mut io::stdout().lock())?;
+ } else {
+ man.render(&mut std::fs::File::create(path)?)?;
+ println!("wrote {path}");
+ }
+ return Ok(ExitCode::SUCCESS);
+ }
+
if args.list_logos {
let mut out = io::stdout().lock();
for name in crate::logo::all_names() {
@@ -166,14 +243,185 @@ fn dispatch(args: &Args) -> Result> {
}
let app = App::new(loaded, options);
- if args.once {
- draw_once(app)?;
- } else {
- run_loop(app)?;
+ if args.diagnose {
+ write_diagnostics(io::stdout().lock(), &app)?;
+ return Ok(ExitCode::SUCCESS);
+ }
+ let format = match args.format {
+ OutputFormat::Auto if !io::stdout().is_terminal() => OutputFormat::Plain,
+ OutputFormat::Auto => OutputFormat::Tui,
+ explicit => explicit,
+ };
+ match format {
+ OutputFormat::Plain => write_plain(io::stdout().lock(), &app)?,
+ OutputFormat::Json => write_json(io::stdout().lock(), &app)?,
+ OutputFormat::Tui | OutputFormat::Auto if args.once => draw_once(app)?,
+ OutputFormat::Tui | OutputFormat::Auto => run_loop(app)?,
}
Ok(ExitCode::SUCCESS)
}
+fn write_plain(mut out: impl Write, app: &App) -> io::Result<()> {
+ let s = &app.statics;
+ let d = &app.dynamic;
+ let (rx, tx) = d.net_rates();
+ let (disk_read, disk_write) = crate::sys::disk_io::total_rate(&d.disk_io);
+ for (key, value) in [
+ ("user", s.username.clone()),
+ ("host", s.hostname.clone()),
+ ("os", s.os_name.clone()),
+ ("kernel", s.kernel.clone()),
+ ("uptime", crate::util::human_uptime(d.uptime)),
+ ("cpu", s.cpu.label(d.cpu.freq_mhz)),
+ ("cpu_percent", format!("{:.1}", d.cpu.total)),
+ ("memory_used", d.mem.used.to_string()),
+ ("memory_total", d.mem.total.to_string()),
+ ("memory_percent", format!("{:.1}", d.mem.percent())),
+ ("network_rx_bytes_per_second", format!("{rx:.0}")),
+ ("network_tx_bytes_per_second", format!("{tx:.0}")),
+ ("disk_read_bytes_per_second", format!("{disk_read:.0}")),
+ ("disk_write_bytes_per_second", format!("{disk_write:.0}")),
+ ("processes", d.proc_total.to_string()),
+ ] {
+ writeln!(out, "{key}: {value}")?;
+ }
+ for disk in &d.disks {
+ writeln!(
+ out,
+ "filesystem: {} {} {} {}",
+ disk.mount, disk.used, disk.total, disk.fstype
+ )?;
+ }
+ for interface in &d.nets {
+ writeln!(
+ out,
+ "interface: {} {} {} {:.0} {:.0}",
+ interface.name,
+ interface.state,
+ interface.ipv4.as_deref().unwrap_or("-"),
+ interface.rx_rate,
+ interface.tx_rate
+ )?;
+ if let Some(ipv6) = &interface.ipv6 {
+ writeln!(out, "interface_ipv6: {} {ipv6}", interface.name)?;
+ }
+ }
+ Ok(())
+}
+
+fn write_json(mut out: impl Write, app: &App) -> io::Result<()> {
+ let s = &app.statics;
+ let d = &app.dynamic;
+ let value = serde_json::json!({
+ "identity": { "user": s.username, "hostname": s.hostname },
+ "system": {
+ "os": s.os_name, "distro_id": s.distro_id, "kernel": s.kernel,
+ "host_model": s.host_model, "uptime_seconds": d.uptime,
+ "shell": s.shell, "terminal": s.terminal, "desktop": s.de,
+ "window_manager": s.wm, "resolution": s.resolution, "locale": s.locale,
+ "packages": s.packages,
+ },
+ "cpu": {
+ "model": s.cpu.model, "physical_cores": s.cpu.cores,
+ "threads": s.cpu.threads, "frequency_mhz": d.cpu.freq_mhz,
+ "percent": d.cpu.total, "per_core_percent": d.cpu.per_core,
+ },
+ "memory": {
+ "total_bytes": d.mem.total, "used_bytes": d.mem.used,
+ "available_bytes": d.mem.available, "percent": d.mem.percent(),
+ "swap_total_bytes": d.mem.swap_total, "swap_used_bytes": d.mem.swap_used,
+ },
+ "gpus": s.gpus.iter().map(|gpu| {
+ let live = d.gpus.iter().find(|stats| stats.card == gpu.card);
+ serde_json::json!({
+ "card": gpu.card, "vendor": gpu.vendor, "model": gpu.model,
+ "driver": gpu.driver,
+ "percent": live.and_then(|stats| stats.usage_percent),
+ "memory_used_bytes": live.and_then(|stats| stats.memory_used),
+ "memory_total_bytes": live.and_then(|stats| stats.memory_total),
+ "temperature_celsius": live.and_then(|stats| stats.temperature_celsius),
+ "power_watts": live.and_then(|stats| stats.power_watts),
+ })
+ }).collect::>(),
+ "filesystems": d.disks.iter().map(|disk| serde_json::json!({
+ "device": disk.device, "mount": disk.mount, "type": disk.fstype,
+ "total_bytes": disk.total, "used_bytes": disk.used,
+ "available_bytes": disk.available,
+ })).collect::>(),
+ "disk_io": d.disk_io.iter().map(|device| serde_json::json!({
+ "device": device.name, "read_bytes": device.read_bytes,
+ "write_bytes": device.write_bytes, "read_bytes_per_second": device.read_rate,
+ "write_bytes_per_second": device.write_rate,
+ })).collect::>(),
+ "network": d.nets.iter().map(|interface| serde_json::json!({
+ "interface": interface.name, "state": interface.state,
+ "ipv4": interface.ipv4, "ipv6": interface.ipv6,
+ "rx_bytes": interface.rx_bytes, "tx_bytes": interface.tx_bytes,
+ "rx_bytes_per_second": interface.rx_rate,
+ "tx_bytes_per_second": interface.tx_rate,
+ })).collect::>(),
+ "processes": d.procs.iter().map(|process| serde_json::json!({
+ "pid": process.pid, "ppid": process.ppid, "uid": process.uid,
+ "name": process.name, "command": process.command, "state": process.state.to_string(),
+ "threads": process.threads, "cpu_percent": process.cpu,
+ "memory_bytes": process.memory,
+ })).collect::>(),
+ });
+ serde_json::to_writer_pretty(&mut out, &value).map_err(io::Error::other)?;
+ writeln!(out)
+}
+
+fn write_diagnostics(mut out: impl Write, app: &App) -> io::Result<()> {
+ writeln!(out, "ratfetch {} diagnostics", crate::VERSION)?;
+ writeln!(
+ out,
+ "platform: {} {}",
+ std::env::consts::OS,
+ std::env::consts::ARCH
+ )?;
+ if app.config_sources().is_empty() {
+ writeln!(out, "config: built-in defaults")?;
+ } else {
+ for path in app.config_sources() {
+ writeln!(out, "config: {}", path.display())?;
+ }
+ }
+
+ for (name, path, optional) in [
+ ("cpu", "/proc/stat", false),
+ ("memory", "/proc/meminfo", false),
+ ("processes", "/proc", false),
+ ("filesystems", "/proc/mounts", false),
+ ("disk io", "/proc/diskstats", false),
+ ("network", "/proc/net/dev", false),
+ ("temperatures", "/sys/class/hwmon", true),
+ ("gpu", "/sys/class/drm", true),
+ ("battery", "/sys/class/power_supply", true),
+ ] {
+ let status = match std::fs::metadata(path) {
+ Ok(_) => "ok".to_string(),
+ Err(error) if optional => format!("optional: {error}"),
+ Err(error) => format!("unavailable: {error}"),
+ };
+ writeln!(out, "{name}: {status} [{path}]")?;
+ }
+
+ writeln!(out, "cpu samples: {}", app.dynamic.cpu.per_core.len())?;
+ writeln!(out, "filesystems found: {}", app.dynamic.disks.len())?;
+ writeln!(out, "block devices found: {}", app.dynamic.disk_io.len())?;
+ writeln!(out, "interfaces found: {}", app.dynamic.nets.len())?;
+ writeln!(out, "processes visible: {}", app.dynamic.procs.len())?;
+ writeln!(
+ out,
+ "temperature sensors found: {}",
+ app.dynamic.temps.len()
+ )?;
+ writeln!(out, "gpus found: {}", app.statics.gpus.len())?;
+ writeln!(out, "gpus with telemetry: {}", app.dynamic.gpus.len())?;
+ writeln!(out, "battery found: {}", app.dynamic.battery.is_some())?;
+ Ok(())
+}
+
/// `--check-config`: report what would be loaded, without starting the UI.
fn check(options: &LoadOptions) -> ExitCode {
match config::load(options) {
@@ -302,6 +550,9 @@ fn event_loop(app: &mut App) -> io::Result<()> {
if event::poll(timeout)? {
match event::read()? {
Event::Key(key) => {
+ if app.handle_text_input(&key) {
+ continue;
+ }
// Ctrl-C is honoured even if the user unbound quit.
if is_interrupt(&key) {
return Ok(());
@@ -402,6 +653,14 @@ mod tests {
assert_eq!(args.generate_config, None);
}
+ #[test]
+ fn generators_parse_their_arguments() {
+ let args = Args::parse_from(["ratfetch", "--generate-completion", "fish"]);
+ assert_eq!(args.generate_completion, Some(CompletionShell::Fish));
+ let args = Args::parse_from(["ratfetch", "--generate-man"]);
+ assert_eq!(args.generate_man.as_deref(), Some(""));
+ }
+
#[test]
fn ctrl_c_is_always_an_interrupt() {
assert!(is_interrupt(&KeyEvent::new(
diff --git a/src/config/enums.rs b/src/config/enums.rs
index f5e9e40..ea04e36 100644
--- a/src/config/enums.rs
+++ b/src/config/enums.rs
@@ -133,6 +133,7 @@ string_enum! {
Swap => "swap",
Disk => "disk",
LocalIp => "local_ip" | "ip",
+ LocalIpv6 => "local_ipv6" | "ipv6",
Battery => "battery",
LoadAvg => "load_avg" | "load",
Processes => "processes" | "procs",
@@ -152,6 +153,7 @@ string_enum! {
Disk => "disk",
Battery => "battery",
Load => "load" | "load_avg",
+ Gpu => "gpu",
}
}
@@ -162,8 +164,10 @@ string_enum! {
Memory => "memory" | "ram",
Swap => "swap",
Network => "network" | "net",
- Disk => "disk" | "disk_io",
+ Disk => "disk" | "disk_usage",
+ DiskIo => "disk_io" | "io",
Load => "load" | "load_avg",
+ Gpu => "gpu",
}
}
diff --git a/src/config/model.rs b/src/config/model.rs
index ee7c725..d8ca9c4 100644
--- a/src/config/model.rs
+++ b/src/config/model.rs
@@ -468,6 +468,13 @@ pub struct Keys {
pub sort_next: Vec,
pub toggle_per_core: Vec,
pub freeze: Vec,
+ pub info_details: Vec,
+ pub errors: Vec,
+ pub search_processes: Vec,
+ pub clear_process_filter: Vec,
+ pub process_details: Vec,
+ pub process_tree: Vec,
+ pub signal_process: Vec,
}
impl Default for Keys {
@@ -484,6 +491,13 @@ impl Default for Keys {
sort_next: b(&["s"]),
toggle_per_core: b(&["c"]),
freeze: b(&["space"]),
+ info_details: b(&["i"]),
+ errors: b(&["e"]),
+ search_processes: b(&["/"]),
+ clear_process_filter: b(&["ctrl+u"]),
+ process_details: b(&["enter"]),
+ process_tree: b(&["t"]),
+ signal_process: b(&["x"]),
}
}
}
diff --git a/src/sys/disk_io.rs b/src/sys/disk_io.rs
new file mode 100644
index 0000000..e37de2e
--- /dev/null
+++ b/src/sys/disk_io.rs
@@ -0,0 +1,145 @@
+//! Block-device throughput from `/proc/diskstats`.
+
+use std::collections::{HashMap, HashSet};
+use std::fs;
+
+/// Live I/O for one whole block device.
+#[derive(Debug, Clone, Default, PartialEq)]
+pub struct DeviceIo {
+ pub name: String,
+ pub read_bytes: u64,
+ pub write_bytes: u64,
+ pub read_rate: f64,
+ pub write_rate: f64,
+}
+
+#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
+struct Counters {
+ read_sectors: u64,
+ write_sectors: u64,
+}
+
+fn parse(text: &str) -> HashMap {
+ text.lines()
+ .filter_map(|line| {
+ let fields: Vec<&str> = line.split_whitespace().collect();
+ let name = fields.get(2)?.to_string();
+ let read_sectors = fields.get(5)?.parse().ok()?;
+ let write_sectors = fields.get(9)?.parse().ok()?;
+ Some((
+ name,
+ Counters {
+ read_sectors,
+ write_sectors,
+ },
+ ))
+ })
+ .collect()
+}
+
+fn whole_devices() -> HashSet {
+ let Ok(entries) = fs::read_dir("/sys/block") else {
+ return HashSet::new();
+ };
+ entries
+ .flatten()
+ .filter_map(|entry| entry.file_name().to_str().map(str::to_string))
+ .filter(|name| {
+ !name.starts_with("loop") && !name.starts_with("ram") && !name.starts_with("zram")
+ })
+ .collect()
+}
+
+/// Turns monotonic sector counters into byte rates.
+pub struct Sampler {
+ prev: HashMap,
+}
+
+impl Sampler {
+ pub fn new() -> Self {
+ Self {
+ prev: HashMap::new(),
+ }
+ }
+
+ pub fn sample(&mut self, elapsed: f64) -> Vec {
+ let text = fs::read_to_string("/proc/diskstats").unwrap_or_default();
+ let counters = parse(&text);
+ let wanted = whole_devices();
+ let mut out = Vec::new();
+
+ for (name, current) in &counters {
+ if !wanted.is_empty() && !wanted.contains(name) {
+ continue;
+ }
+ // Linux diskstats sectors are always expressed as 512-byte units.
+ const SECTOR: u64 = 512;
+ let (read_rate, write_rate) = match self.prev.get(name) {
+ Some(previous) if elapsed > 0.0 => (
+ current.read_sectors.saturating_sub(previous.read_sectors) as f64
+ * SECTOR as f64
+ / elapsed,
+ current.write_sectors.saturating_sub(previous.write_sectors) as f64
+ * SECTOR as f64
+ / elapsed,
+ ),
+ _ => (0.0, 0.0),
+ };
+ out.push(DeviceIo {
+ name: name.clone(),
+ read_bytes: current.read_sectors.saturating_mul(SECTOR),
+ write_bytes: current.write_sectors.saturating_mul(SECTOR),
+ read_rate,
+ write_rate,
+ });
+ }
+
+ self.prev = counters;
+ out.sort_by(|a, b| a.name.cmp(&b.name));
+ out
+ }
+}
+
+impl Default for Sampler {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+pub fn total_rate(devices: &[DeviceIo]) -> (f64, f64) {
+ devices.iter().fold((0.0, 0.0), |(read, write), device| {
+ (read + device.read_rate, write + device.write_rate)
+ })
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ const STATS: &str = "259 0 nvme0n1 10 0 200 0 20 0 400 0 0 0 0 0 0 0 0\n\
+ 259 1 nvme0n1p1 2 0 40 0 3 0 60 0 0 0 0 0 0 0 0\n";
+
+ #[test]
+ fn parses_sector_counters() {
+ let parsed = parse(STATS);
+ assert_eq!(parsed["nvme0n1"].read_sectors, 200);
+ assert_eq!(parsed["nvme0n1"].write_sectors, 400);
+ }
+
+ #[test]
+ fn totals_device_rates() {
+ let devices = vec![
+ DeviceIo {
+ read_rate: 10.0,
+ write_rate: 20.0,
+ ..Default::default()
+ },
+ DeviceIo {
+ read_rate: 2.0,
+ write_rate: 3.0,
+ ..Default::default()
+ },
+ ];
+ assert_eq!(total_rate(&devices), (12.0, 23.0));
+ }
+}
diff --git a/src/sys/gpu.rs b/src/sys/gpu.rs
index 7b8dc3a..2e760b8 100644
--- a/src/sys/gpu.rs
+++ b/src/sys/gpu.rs
@@ -9,12 +9,24 @@ use super::read_trimmed;
/// A graphics adapter.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Gpu {
+ pub card: String,
pub vendor: String,
pub model: String,
/// The kernel driver bound to it, e.g. `amdgpu`.
pub driver: String,
}
+/// Live telemetry exposed by a DRM driver's sysfs nodes.
+#[derive(Debug, Clone, Default, PartialEq)]
+pub struct GpuStats {
+ pub card: String,
+ pub usage_percent: Option,
+ pub memory_used: Option,
+ pub memory_total: Option,
+ pub temperature_celsius: Option,
+ pub power_watts: Option,
+}
+
impl Gpu {
/// `AMD Radeon RX 7900 XTX`, or the best available approximation.
pub fn label(&self) -> String {
@@ -65,10 +77,14 @@ pub fn list() -> Vec {
for card in cards {
let device = card.path().join("device");
- let Some(gpu) = read_card(&device, db.as_ref()) else {
+ let Some(mut gpu) = read_card(&device, db.as_ref()) else {
continue;
};
- if !out.contains(&gpu) {
+ gpu.card = card.file_name().to_string_lossy().into_owned();
+ if !out
+ .iter()
+ .any(|known| known.vendor == gpu.vendor && known.model == gpu.model)
+ {
out.push(gpu);
}
}
@@ -96,12 +112,68 @@ fn read_card(device: &Path, db: Option<&PciIds>) -> Option {
.unwrap_or_default();
Some(Gpu {
+ card: String::new(),
vendor,
model,
driver,
})
}
+/// Reads every live field the active kernel drivers expose.
+pub fn stats() -> Vec {
+ let Ok(entries) = fs::read_dir("/sys/class/drm") else {
+ return Vec::new();
+ };
+ let mut out = Vec::new();
+ for entry in entries.flatten() {
+ let card = entry.file_name().to_string_lossy().into_owned();
+ if !card.starts_with("card") || card.contains('-') {
+ continue;
+ }
+ let device = entry.path().join("device");
+ let usage_percent = read_trimmed(device.join("gpu_busy_percent"))
+ .and_then(|value| value.parse::().ok())
+ .map(|value| value.clamp(0.0, 100.0));
+ let memory_used = super::read_u64(device.join("mem_info_vram_used"));
+ let memory_total = super::read_u64(device.join("mem_info_vram_total"));
+
+ let mut temperature_celsius = None;
+ let mut power_watts = None;
+ if let Ok(hwmons) = fs::read_dir(device.join("hwmon")) {
+ for hwmon in hwmons.flatten() {
+ if temperature_celsius.is_none() {
+ temperature_celsius = super::read_u64(hwmon.path().join("temp1_input"))
+ .map(|milli| milli as f64 / 1000.0)
+ .filter(|value| (-50.0..=150.0).contains(value));
+ }
+ if power_watts.is_none() {
+ power_watts = super::read_u64(hwmon.path().join("power1_average"))
+ .or_else(|| super::read_u64(hwmon.path().join("power1_input")))
+ .map(|microwatts| microwatts as f64 / 1_000_000.0);
+ }
+ }
+ }
+
+ if usage_percent.is_some()
+ || memory_used.is_some()
+ || memory_total.is_some()
+ || temperature_celsius.is_some()
+ || power_watts.is_some()
+ {
+ out.push(GpuStats {
+ card,
+ usage_percent,
+ memory_used,
+ memory_total,
+ temperature_celsius,
+ power_watts,
+ });
+ }
+ }
+ out.sort_by(|a, b| a.card.cmp(&b.card));
+ out
+}
+
/// Reads a `0x10de`-style sysfs id.
fn read_hex_id(path: impl AsRef) -> Option {
let text = read_trimmed(path)?;
@@ -213,6 +285,7 @@ mod tests {
#[test]
fn label_degrades_gracefully() {
let g = Gpu {
+ card: "card0".into(),
vendor: "AMD".into(),
model: "Navi 31".into(),
driver: "amdgpu".into(),
@@ -232,5 +305,6 @@ mod tests {
fn listing_never_panics() {
// Headless machines and containers have no /sys/class/drm.
let _ = list();
+ let _ = stats();
}
}
diff --git a/src/sys/mod.rs b/src/sys/mod.rs
index 95e2d82..dbb341a 100644
--- a/src/sys/mod.rs
+++ b/src/sys/mod.rs
@@ -9,6 +9,7 @@ pub mod battery;
pub mod cpu;
pub mod desktop;
pub mod disk;
+pub mod disk_io;
pub mod gpu;
pub mod host;
pub mod mem;
@@ -81,15 +82,18 @@ pub struct Dynamic {
pub cpu: cpu::CpuUsage,
pub mem: mem::Memory,
pub disks: Vec,
+ pub disk_io: Vec,
pub nets: Vec,
pub battery: Option,
pub temps: Vec,
+ pub gpus: Vec,
pub load: [f64; 3],
pub procs: Vec,
pub proc_total: usize,
pub thread_total: usize,
pub users: usize,
pub local_ip: String,
+ pub local_ipv6: String,
}
impl Dynamic {
@@ -126,6 +130,7 @@ impl Dynamic {
pub struct Sampler {
cpu: cpu::Sampler,
net: net::Sampler,
+ disk_io: disk_io::Sampler,
procs: procs::Sampler,
last: Option,
}
@@ -135,6 +140,7 @@ impl Sampler {
Self {
cpu: cpu::Sampler::new(),
net: net::Sampler::new(),
+ disk_io: disk_io::Sampler::new(),
procs: procs::Sampler::new(),
last: None,
}
@@ -154,21 +160,25 @@ impl Sampler {
let nets = self.net.sample(elapsed, &cfg.network);
let local_ip = net::local_ip(&nets);
+ let local_ipv6 = net::local_ipv6(&nets);
Dynamic {
uptime: os::uptime(),
cpu: self.cpu.sample(),
mem: mem::Memory::load(),
disks: disk::list(&cfg.disks),
+ disk_io: self.disk_io.sample(elapsed),
nets,
battery: battery::load(),
temps: temp::list(),
+ gpus: gpu::stats(),
load: os::load_average(),
procs: self.procs.sample(elapsed, &cfg.processes),
proc_total: self.procs.last_total,
thread_total: self.procs.last_threads,
users: os::logged_in_users(),
local_ip,
+ local_ipv6,
}
}
}
diff --git a/src/sys/net.rs b/src/sys/net.rs
index 2582e8a..b3f7cd4 100644
--- a/src/sys/net.rs
+++ b/src/sys/net.rs
@@ -4,6 +4,7 @@
use std::collections::HashMap;
use std::ffi::CStr;
use std::fs;
+use std::net::Ipv6Addr;
use super::read_trimmed;
use crate::config::model::Network as NetCfg;
@@ -23,6 +24,8 @@ pub struct Interface {
pub state: String,
/// The first IPv4 address bound to the interface.
pub ipv4: Option,
+ /// The first global IPv6 address, or a link-local address as fallback.
+ pub ipv6: Option,
pub is_loopback: bool,
}
@@ -86,7 +89,7 @@ impl Sampler {
pub fn sample(&mut self, elapsed: f64, cfg: &NetCfg) -> Vec {
let text = fs::read_to_string("/proc/net/dev").unwrap_or_default();
- let addrs = ipv4_addresses();
+ let addrs = ip_addresses();
let mut out = Vec::new();
let mut current = HashMap::new();
@@ -117,7 +120,12 @@ impl Sampler {
rx_rate,
tx_rate,
state,
- ipv4: addrs.get(&name).cloned(),
+ ipv4: addrs
+ .get(&name)
+ .and_then(|addresses| addresses.ipv4.clone()),
+ ipv6: addrs
+ .get(&name)
+ .and_then(|addresses| addresses.ipv6.clone()),
is_loopback,
name,
};
@@ -140,8 +148,14 @@ impl Default for Sampler {
}
}
-/// Maps interface name to its first IPv4 address.
-fn ipv4_addresses() -> HashMap {
+#[derive(Debug, Clone, Default)]
+struct Addresses {
+ ipv4: Option,
+ ipv6: Option,
+}
+
+/// Maps interface names to their first IPv4 and preferred IPv6 addresses.
+fn ip_addresses() -> HashMap {
let mut out = HashMap::new();
let mut head: *mut libc::ifaddrs = std::ptr::null_mut();
@@ -161,19 +175,35 @@ fn ipv4_addresses() -> HashMap {
if entry.ifa_addr.is_null() || entry.ifa_name.is_null() {
continue;
}
- if (*entry.ifa_addr).sa_family != libc::AF_INET as libc::sa_family_t {
- continue;
- }
-
- let addr = &*(entry.ifa_addr as *const libc::sockaddr_in);
- let octets = addr.sin_addr.s_addr.to_ne_bytes();
let Ok(name) = CStr::from_ptr(entry.ifa_name).to_str() else {
continue;
};
-
- out.entry(name.to_string()).or_insert_with(|| {
- format!("{}.{}.{}.{}", octets[0], octets[1], octets[2], octets[3])
- });
+ let addresses = out
+ .entry(name.to_string())
+ .or_insert_with(Addresses::default);
+ match (*entry.ifa_addr).sa_family as i32 {
+ libc::AF_INET => {
+ let addr = &*(entry.ifa_addr as *const libc::sockaddr_in);
+ let octets = addr.sin_addr.s_addr.to_ne_bytes();
+ addresses.ipv4.get_or_insert_with(|| {
+ format!("{}.{}.{}.{}", octets[0], octets[1], octets[2], octets[3])
+ });
+ }
+ libc::AF_INET6 => {
+ let addr = &*(entry.ifa_addr as *const libc::sockaddr_in6);
+ let ip = Ipv6Addr::from(addr.sin6_addr.s6_addr);
+ let replace_link_local = addresses.ipv6.as_ref().is_some_and(|current| {
+ current
+ .parse::()
+ .is_ok_and(|current| current.is_unicast_link_local())
+ && !ip.is_unicast_link_local()
+ });
+ if addresses.ipv6.is_none() || replace_link_local {
+ addresses.ipv6 = Some(ip.to_string());
+ }
+ }
+ _ => {}
+ }
}
}
@@ -191,6 +221,14 @@ pub fn local_ip(interfaces: &[Interface]) -> String {
.unwrap_or_default()
}
+pub fn local_ipv6(interfaces: &[Interface]) -> String {
+ interfaces
+ .iter()
+ .filter(|interface| !interface.is_loopback)
+ .find_map(|interface| interface.ipv6.clone())
+ .unwrap_or_default()
+}
+
#[cfg(test)]
mod tests {
use super::*;
@@ -284,6 +322,22 @@ docker0: 100 1 0 0 0 0 0 0 0 0
assert_eq!(local_ip(&[]), "");
}
+ #[test]
+ fn local_ipv6_skips_loopback() {
+ let interfaces = vec![
+ Interface {
+ ipv6: Some("::1".into()),
+ is_loopback: true,
+ ..Default::default()
+ },
+ Interface {
+ ipv6: Some("2001:db8::10".into()),
+ ..Default::default()
+ },
+ ];
+ assert_eq!(local_ipv6(&interfaces), "2001:db8::10");
+ }
+
#[test]
fn up_covers_the_unknown_state() {
let up = Interface {
diff --git a/src/sys/procs.rs b/src/sys/procs.rs
index 13addb6..2b406a6 100644
--- a/src/sys/procs.rs
+++ b/src/sys/procs.rs
@@ -10,6 +10,7 @@ use crate::config::model::Processes as ProcCfg;
#[derive(Debug, Clone, Default, PartialEq)]
pub struct Process {
pub pid: i32,
+ pub ppid: i32,
pub name: String,
/// The full command line, or the name again when it is unreadable.
pub command: String,
@@ -38,6 +39,7 @@ impl Process {
struct Stat {
name: String,
state: char,
+ ppid: i32,
ticks: u64,
rss_pages: u64,
threads: u64,
@@ -59,6 +61,7 @@ fn parse_stat(text: &str) -> Option {
Some(Stat {
name,
state: fields.first().and_then(|s| s.chars().next()).unwrap_or('?'),
+ ppid: fields.get(1).and_then(|f| f.parse().ok()).unwrap_or(0),
ticks: get(14) + get(15), // utime + stime
rss_pages: get(24),
threads: get(20),
@@ -166,6 +169,7 @@ impl Sampler {
out.push(Process {
pid,
+ ppid: stat.ppid,
name: stat.name,
command,
state: stat.state,
@@ -213,6 +217,69 @@ pub fn sort(procs: &mut [Process], by: ProcSort, ascending: bool) {
});
}
+/// Processes prepared for the interactive table, paired with their tree depth.
+pub fn view<'a>(procs: &'a [Process], query: &str, tree: bool) -> Vec<(&'a Process, usize)> {
+ let query = query.trim().to_ascii_lowercase();
+ let matches = |process: &Process| {
+ query.is_empty()
+ || process.name.to_ascii_lowercase().contains(&query)
+ || process.command.to_ascii_lowercase().contains(&query)
+ || process.pid.to_string().contains(&query)
+ };
+
+ if !tree {
+ return procs
+ .iter()
+ .filter(|process| matches(process))
+ .map(|process| (process, 0))
+ .collect();
+ }
+
+ use std::collections::{HashMap, HashSet};
+ let known: HashSet = procs.iter().map(|process| process.pid).collect();
+ let mut children: HashMap> = HashMap::new();
+ let mut roots = Vec::new();
+ for process in procs {
+ if process.ppid > 0 && known.contains(&process.ppid) && process.ppid != process.pid {
+ children.entry(process.ppid).or_default().push(process);
+ } else {
+ roots.push(process);
+ }
+ }
+
+ fn walk<'a>(
+ process: &'a Process,
+ depth: usize,
+ children: &HashMap>,
+ seen: &mut HashSet,
+ out: &mut Vec<(&'a Process, usize)>,
+ ) {
+ if !seen.insert(process.pid) {
+ return;
+ }
+ out.push((process, depth));
+ if let Some(nodes) = children.get(&process.pid) {
+ for child in nodes {
+ walk(child, depth.saturating_add(1), children, seen, out);
+ }
+ }
+ }
+
+ let mut ordered = Vec::with_capacity(procs.len());
+ let mut seen = HashSet::new();
+ for root in roots {
+ walk(root, 0, &children, &mut seen, &mut ordered);
+ }
+ // Malformed parent cycles are still shown once.
+ for process in procs {
+ walk(process, 0, &children, &mut seen, &mut ordered);
+ }
+ ordered
+ .into_iter()
+ .filter(|(process, _)| matches(process))
+ .collect()
+}
+
#[cfg(test)]
mod tests {
use super::*;
@@ -226,6 +293,7 @@ mod tests {
let s = parse_stat(STAT).unwrap();
assert_eq!(s.name, "my prog");
assert_eq!(s.state, 'S');
+ assert_eq!(s.ppid, 1);
assert_eq!(s.ticks, 200, "utime 150 + stime 50");
assert_eq!(s.threads, 7);
assert_eq!(s.rss_pages, 2048);
@@ -307,6 +375,22 @@ mod tests {
assert_eq!(proc.display(true), "/usr/bin/prog --flag");
}
+ #[test]
+ fn view_filters_and_builds_a_tree() {
+ let mut parent = p(10, "shell", 0.0, 0);
+ parent.ppid = 1;
+ let mut child = p(20, "worker", 0.0, 0);
+ child.ppid = 10;
+ let procs = vec![child, parent];
+
+ let tree = view(&procs, "", true);
+ assert_eq!(
+ tree.iter().map(|(p, d)| (p.pid, *d)).collect::>(),
+ vec![(10, 0), (20, 1)]
+ );
+ assert_eq!(view(&procs, "work", false)[0].0.pid, 20);
+ }
+
#[test]
#[cfg(target_os = "linux")]
fn reads_this_machine() {
diff --git a/src/ui/chrome.rs b/src/ui/chrome.rs
index fb99569..f19ffa4 100644
--- a/src/ui/chrome.rs
+++ b/src/ui/chrome.rs
@@ -3,7 +3,7 @@
use ratatui::layout::{Alignment, Constraint, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
-use ratatui::widgets::{Clear, Paragraph};
+use ratatui::widgets::{Clear, Paragraph, Wrap};
use ratatui::Frame;
use crate::app::App;
@@ -30,11 +30,17 @@ pub fn tab_bar(frame: &mut Frame, area: Rect, app: &App) {
}
let left = Line::from(spans);
- let right = Line::from(vec![
+ let mut right = Line::from(vec![
Span::styled(if app.frozen { "⏸ frozen " } else { "" }, theme.warn.fg()),
Span::styled(format!("ratfetch {} ", crate::VERSION), theme.muted.fg()),
]);
+ if left.width().saturating_add(right.width()) > area.width as usize {
+ right = Line::from(Span::styled(
+ if app.frozen { "⏸ frozen " } else { "" },
+ theme.warn.fg(),
+ ));
+ }
let chunks = Layout::horizontal([Constraint::Min(0), Constraint::Length(right.width() as u16)])
.split(area);
@@ -78,6 +84,10 @@ pub fn footer(frame: &mut Frame, area: Rect, app: &App) {
crate::util::truncate(&status.text, area.width.saturating_sub(3) as usize),
color.fg(),
),
+ Span::styled(
+ if status.is_error { " e details" } else { "" },
+ theme.footer.fg(),
+ ),
]);
frame.render_widget(Paragraph::new(line).style(theme.background.bg()), area);
return;
@@ -95,6 +105,14 @@ pub fn footer(frame: &mut Frame, area: Rect, app: &App) {
app,
));
spans.extend(hint(&k.reload, "reload", app));
+ if app.tab == crate::config::enums::Tab::Overview {
+ spans.extend(hint(&k.info_details, "info", app));
+ }
+ if app.tab == crate::config::enums::Tab::Processes {
+ spans.extend(hint(&k.search_processes, "filter", app));
+ spans.extend(hint(&k.process_details, "details", app));
+ }
+ spans.extend(hint(&k.scroll_down, "scroll", app));
frame.render_widget(
Paragraph::new(Line::from(spans)).style(theme.background.bg()),
@@ -172,7 +190,7 @@ pub fn help(frame: &mut Frame, area: Rect, app: &App) {
let theme = &app.theme;
let k = &app.config.keys;
- let entries: [(&str, &[KeyBinding]); 10] = [
+ let entries: [(&str, &[KeyBinding]); 17] = [
("quit", &k.quit),
("this help", &k.help),
("reload config", &k.reload),
@@ -183,6 +201,13 @@ pub fn help(frame: &mut Frame, area: Rect, app: &App) {
("cycle sort column", &k.sort_next),
("toggle per-core meters", &k.toggle_per_core),
("freeze / resume", &k.freeze),
+ ("full system information", &k.info_details),
+ ("error details", &k.errors),
+ ("filter processes", &k.search_processes),
+ ("clear process filter", &k.clear_process_filter),
+ ("selected process details", &k.process_details),
+ ("toggle process tree", &k.process_tree),
+ ("signal selected process", &k.signal_process),
];
let key_column = entries
@@ -243,6 +268,88 @@ pub fn help(frame: &mut Frame, area: Rect, app: &App) {
);
}
+fn text_popup(frame: &mut Frame, area: Rect, app: &App, title: &str, lines: Vec>) {
+ let width = area.width.saturating_sub(4).clamp(1, 100);
+ let height = (lines.len() as u16 + 2)
+ .min(area.height.saturating_sub(2))
+ .max(1);
+ let popup = centred(area, width, height);
+ frame.render_widget(Clear, popup);
+ let block = super::panel(&app.theme, title).border_style(app.theme.border_focus.fg());
+ let inner = block.inner(popup);
+ frame.render_widget(block, popup);
+ frame.render_widget(
+ Paragraph::new(lines)
+ .wrap(Wrap { trim: false })
+ .style(super::surface(&app.theme)),
+ inner,
+ );
+}
+
+/// All system information without the overview's one-line truncation.
+pub fn info_details(frame: &mut Frame, area: Rect, app: &App) {
+ text_popup(
+ frame,
+ area,
+ app,
+ " Information — i/esc close ",
+ super::header::detail_lines(app),
+ );
+}
+
+/// Recent errors, retained after the footer notification is dismissed.
+pub fn errors(frame: &mut Frame, area: Rect, app: &App) {
+ let lines = if app.errors.is_empty() {
+ vec![Line::from("No errors recorded.")]
+ } else {
+ app.errors
+ .iter()
+ .rev()
+ .map(|error| Line::from(format!("! {error}")))
+ .collect()
+ };
+ text_popup(frame, area, app, " Errors — e/esc close ", lines);
+}
+
+pub fn process_details(frame: &mut Frame, area: Rect, app: &App) {
+ let Some(process) = app.selected_process() else {
+ return;
+ };
+ let lines = vec![
+ Line::from(format!("Name {}", process.name)),
+ Line::from(format!("PID {}", process.pid)),
+ Line::from(format!("Parent {}", process.ppid)),
+ Line::from(format!("UID {}", process.uid)),
+ Line::from(format!("State {}", process.state)),
+ Line::from(format!("Threads {}", process.threads)),
+ Line::from(format!("CPU {:.1}%", process.cpu)),
+ Line::from(format!(
+ "Memory {}",
+ crate::util::human_bytes(process.memory)
+ )),
+ Line::from(""),
+ Line::from(format!("Command {}", process.display(true))),
+ ];
+ text_popup(frame, area, app, " Process — enter/esc close ", lines);
+}
+
+pub fn signal_confirm(frame: &mut Frame, area: Rect, app: &App) {
+ let Some(process) = app.selected_process() else {
+ return;
+ };
+ text_popup(
+ frame,
+ area,
+ app,
+ " Send SIGTERM? ",
+ vec![
+ Line::from(format!("Terminate {} ({})?", process.name, process.pid)),
+ Line::from(""),
+ Line::from("y send SIGTERM n/esc cancel"),
+ ],
+ );
+}
+
#[cfg(test)]
mod tests {
use super::*;
diff --git a/src/ui/graphs.rs b/src/ui/graphs.rs
index bb7b388..3c28382 100644
--- a/src/ui/graphs.rs
+++ b/src/ui/graphs.rs
@@ -27,9 +27,11 @@ struct Series<'a> {
/// floor that keeps an idle graph from looking busy.
fn ceiling_for(kind: GraphKind, ring: &Ring, app: &App) -> f64 {
match kind {
- GraphKind::Cpu | GraphKind::Memory | GraphKind::Swap | GraphKind::Disk => 100.0,
+ GraphKind::Cpu | GraphKind::Memory | GraphKind::Swap | GraphKind::Disk | GraphKind::Gpu => {
+ 100.0
+ }
GraphKind::Load => (app.statics.cpu.threads.max(1) as f64).max(ring.max()),
- GraphKind::Network => ring.max().max(64.0 * 1024.0),
+ GraphKind::Network | GraphKind::DiskIo => ring.max().max(64.0 * 1024.0),
}
}
@@ -64,6 +66,12 @@ fn collect<'a>(app: &'a App) -> Vec> {
ceiling: ceiling_for(*kind, &h.memory, app),
color: theme.graph_secondary,
}],
+ GraphKind::Gpu => vec![Series {
+ title: percent_title("GPU", &h.gpu),
+ ring: &h.gpu,
+ ceiling: 100.0,
+ color: theme.accent,
+ }],
GraphKind::Swap => vec![Series {
title: percent_title("Swap", &h.swap),
ring: &h.swap,
@@ -71,7 +79,7 @@ fn collect<'a>(app: &'a App) -> Vec> {
color: theme.warn,
}],
GraphKind::Disk => vec![Series {
- title: percent_title("Disk", &h.disk),
+ title: percent_title("Disk use", &h.disk),
ring: &h.disk,
ceiling: ceiling_for(*kind, &h.disk, app),
color: theme.accent,
@@ -86,6 +94,39 @@ fn collect<'a>(app: &'a App) -> Vec> {
ceiling: ceiling_for(*kind, &h.load, app),
color: theme.critical,
}],
+ GraphKind::DiskIo => {
+ let ceiling = h.disk_read.max().max(h.disk_write.max()).max(64.0 * 1024.0);
+ vec![
+ Series {
+ title: if show_stats {
+ format!(
+ " read {} (peak {}) ",
+ human_rate(h.disk_read.last()),
+ human_rate(h.disk_read.max())
+ )
+ } else {
+ " read ".to_string()
+ },
+ ring: &h.disk_read,
+ ceiling,
+ color: theme.good,
+ },
+ Series {
+ title: if show_stats {
+ format!(
+ " write {} (peak {}) ",
+ human_rate(h.disk_write.last()),
+ human_rate(h.disk_write.max())
+ )
+ } else {
+ " write ".to_string()
+ },
+ ring: &h.disk_write,
+ ceiling,
+ color: theme.graph_secondary,
+ },
+ ]
+ }
// Receive and transmit share a scale so the two are comparable.
GraphKind::Network => {
let ceiling = h.net_rx.max().max(h.net_tx.max()).max(64.0 * 1024.0);
@@ -281,7 +322,7 @@ mod tests {
let mut config = Config::default();
config.graphs.items = GraphKind::ALL.to_vec();
let app = app_with(config);
- // Network counts twice.
- assert_eq!(collect(&app).len(), GraphKind::ALL.len() + 1);
+ // Network and disk I/O each expand into two series.
+ assert_eq!(collect(&app).len(), GraphKind::ALL.len() + 2);
}
}
diff --git a/src/ui/header.rs b/src/ui/header.rs
index 47c846a..0e75342 100644
--- a/src/ui/header.rs
+++ b/src/ui/header.rs
@@ -57,7 +57,31 @@ fn rows(app: &App) -> Vec