Files
shell-rs/src/monitor/linux.rs
T
XuzandClaude Opus 5.5 8a0436caf6 系统监控:右侧栏的第一个工具,经终端自己的 SSH 连接读取 Linux 主机的 CPU、内存、网络和磁盘
- 右侧栏只跟远程终端出现,已知不是 Linux 的主机不显示监控图标,最窄 320 px
- 端口转发、凭据列表和开始页的滚动条挂到不滚动的外层上,修正滑块位置

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 13:34:23 +08:00

503 lines
19 KiB
Rust

//! Reading a Linux host: one command, and the parser for what it prints.
use std::collections::HashMap;
use std::time::Duration;
use super::model::{CpuTimes, DiskUsage, InterfaceCounters, Memory, Reading, SystemInfo};
/// Which parts a reading asks for besides the load, which every reading
/// does.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Parts {
/// Host name, architecture, distribution and CPU model: once per
/// connection, as they do not change while connected.
pub system: bool,
/// Every half minute or so: disks fill slowly, and `df` is the slowest
/// part of a reading.
pub disks: bool,
}
/// The parts asked once per connection.
const SYSTEM: &str = "echo @@host; uname -n; echo @@arch; uname -m; \
echo @@os; (. /etc/os-release && echo \"$PRETTY_NAME\") 2>/dev/null || uname -sr; \
echo @@cpu; grep -E \"^(model name|Hardware|Processor|cpu model)\" /proc/cpuinfo | head -n 1; ";
/// The load, every reading. Shell built-ins only (`read`, `case`, `echo`),
/// so the one process a reading starts on the host is the `sh` running it;
/// `/proc/stat` and `/proc/meminfo` are read only as far as the lines it
/// needs, which come first.
const LOAD: &str = "echo @@uptime; read -r line < /proc/uptime; echo \"$line\"; \
echo @@stat; while read -r line; do case \"$line\" in cpu*) echo \"$line\";; *) break;; esac; \
done < /proc/stat; \
echo @@memory; while read -r line; do case \"$line\" in \
MemTotal:*|MemFree:*|MemAvailable:*|Buffers:*|Cached:*|SwapTotal:*) echo \"$line\";; \
SwapFree:*) echo \"$line\"; break;; esac; done < /proc/meminfo; \
echo @@net; while read -r line; do echo \"$line\"; done < /proc/net/dev; \
echo @@route; while read -r line; do echo \"$line\"; done < /proc/net/route; ";
/// `df -l` keeps to local file systems, so a dead network mount cannot hold
/// it up; a `df` without `-l` (BusyBox) runs plain.
const DISKS: &str = "echo @@df; df -kPl 2>/dev/null || df -kP 2>/dev/null; ";
/// What the monitor runs on the host for a reading: `/proc` and `df`, each
/// part under an `@@` line of its own.
///
/// One line, in single quotes for `sh -c`, so that whatever the login shell
/// is (bash, zsh, fish, csh) it hands the script to `sh` untouched: the
/// script has no single quote, and no `!` for csh to expand.
pub fn command(parts: Parts) -> String {
format!(
"sh -c 'export LC_ALL=C; if test -r /proc/stat; then {}{}{}\
else echo @@unsupported; uname -s; fi'",
if parts.system { SYSTEM } else { "" },
LOAD,
if parts.disks { DISKS } else { "" },
)
}
/// What the command's output said.
#[derive(Debug, PartialEq)]
pub enum Parsed {
Reading(Box<Reading>),
/// Not a Linux host. Holds `uname -s` when the host has one: 「Darwin」.
Unsupported(Option<String>),
}
pub fn parse(output: &str) -> Parsed {
let mut sections: HashMap<&str, Vec<&str>> = HashMap::new();
let mut current = None;
for line in output.lines() {
if let Some(name) = line.strip_prefix("@@") {
current = Some(name.trim());
sections.entry(name.trim()).or_default();
} else if let Some(name) = current {
sections.entry(name).or_default().push(line);
}
}
let section = |name: &str| sections.get(name).map(Vec::as_slice).unwrap_or_default();
let first = |name: &str| {
section(name)
.iter()
.map(|line| line.trim())
.find(|line| !line.is_empty())
.map(str::to_owned)
};
let (cpu, cores) = parse_stat(section("stat"));
let Some(cpu) = cpu else {
// A shell other than sh's (cmd.exe) prints nothing we know.
return Parsed::Unsupported(first("unsupported"));
};
Parsed::Reading(Box::new(Reading {
// Only when the reading asked for it.
system: sections.contains_key("host").then(|| SystemInfo {
host_name: first("host").unwrap_or_default(),
arch: first("arch").unwrap_or_default(),
os: first("os").unwrap_or_else(|| "Linux".into()),
cpu_model: first("cpu")
.and_then(|line| Some(line.split_once(':')?.1.trim().to_owned()))
.unwrap_or_default(),
}),
uptime: first("uptime")
.and_then(|line| line.split_whitespace().next()?.parse::<f64>().ok())
.map(Duration::from_secs_f64),
cpu,
cores,
memory: parse_memory(section("memory")),
interfaces: parse_interfaces(section("net")),
default_route: parse_default_route(section("route")),
disks: sections
.contains_key("df")
.then(|| parse_disks(section("df"))),
}))
}
/// `/proc/stat`'s `cpu` lines: the machine, then each core.
fn parse_stat(lines: &[&str]) -> (Option<CpuTimes>, Vec<CpuTimes>) {
let mut total = None;
let mut cores = Vec::new();
for line in lines {
let mut fields = line.split_whitespace();
let Some(name) = fields.next() else { continue };
// user nice system idle iowait irq softirq steal; guest time is
// already counted in user.
let ticks: Vec<u64> = fields
.take(8)
.filter_map(|field| field.parse().ok())
.collect();
if ticks.len() < 4 {
continue;
}
let tick = |index: usize| ticks.get(index).copied().unwrap_or(0);
let times = CpuTimes {
busy: tick(0) + tick(1) + tick(2) + tick(5) + tick(6) + tick(7),
idle: tick(3) + tick(4),
};
if name == "cpu" {
total = Some(times);
} else if name.starts_with("cpu") {
cores.push(times);
}
}
(total, cores)
}
/// `/proc/meminfo`, whose sizes are in KiB.
fn parse_memory(lines: &[&str]) -> Memory {
let values: HashMap<&str, u64> = lines
.iter()
.filter_map(|line| {
let (name, value) = line.split_once(':')?;
let kib = value.split_whitespace().next()?.parse::<u64>().ok()?;
Some((name.trim(), kib * 1024))
})
.collect();
let value = |name: &str| values.get(name).copied().unwrap_or(0);
let total = value("MemTotal");
// Kernels before 3.14 have no MemAvailable; free, buffers and cache come
// closest.
let available = values
.get("MemAvailable")
.copied()
.unwrap_or_else(|| value("MemFree") + value("Buffers") + value("Cached"));
Memory {
total,
used: total.saturating_sub(available),
swap_total: value("SwapTotal"),
swap_used: value("SwapTotal").saturating_sub(value("SwapFree")),
}
}
/// `/proc/net/dev`: received bytes first, transmitted bytes ninth.
fn parse_interfaces(lines: &[&str]) -> Vec<InterfaceCounters> {
lines
.iter()
.filter_map(|line| {
let (name, counters) = line.split_once(':')?;
let counters: Vec<u64> = counters
.split_whitespace()
.map(|field| field.parse().ok())
.collect::<Option<_>>()?;
Some(InterfaceCounters {
name: name.trim().to_owned(),
received: *counters.first()?,
transmitted: *counters.get(8)?,
})
})
.collect()
}
/// `/proc/net/route`: the interface of the route to everywhere (destination
/// and mask all zeros), the lowest metric when there are several.
fn parse_default_route(lines: &[&str]) -> Option<String> {
lines
.iter()
.filter_map(|line| {
let fields: Vec<&str> = line.split_whitespace().collect();
let (name, destination, metric, mask) = (
fields.first()?,
fields.get(1)?,
fields.get(6)?,
fields.get(7)?,
);
(*destination == "00000000" && *mask == "00000000")
.then(|| (metric.parse::<u32>().unwrap_or(u32::MAX), name.to_string()))
})
.min()
.map(|(_, name)| name)
}
/// `df -kP`, without what is not a disk: memory file systems, the kernel's
/// own, snaps and container layers.
fn parse_disks(lines: &[&str]) -> Vec<DiskUsage> {
let mut disks: Vec<DiskUsage> = Vec::new();
for line in lines {
let fields: Vec<&str> = line.split_whitespace().collect();
if fields.len() < 6 {
continue;
}
let kib = |index: usize| fields[index].parse::<u64>().ok().map(|kib| kib * 1024);
let (Some(total), Some(used), Some(available)) = (kib(1), kib(2), kib(3)) else {
// The heading.
continue;
};
// A mount point can hold spaces.
let mount = fields[5..].join(" ");
if total == 0 || !is_disk(fields[0], &mount) || disks.iter().any(|d| d.mount == mount) {
continue;
}
disks.push(DiskUsage {
mount,
total,
used,
available,
});
}
disks
}
fn is_disk(source: &str, mount: &str) -> bool {
const NOT_DISKS: [&str; 8] = [
"tmpfs", "devtmpfs", "udev", "overlay", "shm", "none", "run", "efivarfs",
];
const SYSTEM_MOUNTS: [&str; 6] = [
"/dev",
"/run",
"/sys",
"/proc",
"/snap/",
"/var/lib/docker/",
];
!NOT_DISKS.contains(&source)
&& !source.starts_with("/dev/loop")
&& !SYSTEM_MOUNTS.iter().any(|prefix| mount.starts_with(prefix))
}
#[cfg(test)]
mod tests {
use super::*;
/// What a small Debian VPS answers.
const DEBIAN: &str = "\
@@host
VM236A61A0D4D4D22
@@arch
x86_64
@@os
Debian GNU/Linux 12 (bookworm)
@@uptime
5359162.33 5293480.12
@@cpu
model name\t: AMD EPYC-Milan Processor
@@stat
cpu 4705 356 584 3699176 2311 0 158 77 0 0
cpu0 4705 356 584 3699176 2311 0 158 77 0 0
@@memory
MemTotal: 996608 kB
MemFree: 120000 kB
MemAvailable: 565480 kB
Buffers: 40000 kB
Cached: 400000 kB
SwapTotal: 1048572 kB
SwapFree: 996144 kB
@@net
Inter-| Receive | Transmit
face |bytes packets errs drop fifo frame compressed multicast|bytes packets errs drop fifo colls carrier compressed
lo: 12345 67 0 0 0 0 0 0 12345 67 0 0 0 0 0 0
eth0: 987654321 1000 0 0 0 0 0 0 123456789 900 0 0 0 0 0 0
docker0: 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
@@route
Iface Destination Gateway Flags RefCnt Use Metric Mask MTU Window IRTT
eth0 00000000 0101A8C0 0003 0 0 100 00000000 0 0 0
wg0 00000000 00000000 0001 0 0 50 80000000 0 0 0
eth0 0001A8C0 00000000 0001 0 0 100 00FFFFFF 0 0 0
@@df
Filesystem 1024-blocks Used Available Capacity Mounted on
udev 483152 0 483152 0% /dev
tmpfs 99664 560 99104 1% /run
/dev/vda1 9656904 3240180 5994708 36% /
tmpfs 498304 0 498304 0% /dev/shm
/dev/vda15 126678 12068 114610 10% /boot/efi
/dev/loop0 65536 65536 0 100% /snap/core20/2105
/dev/sdb1 20511312 102400 19343952 1% /mnt/My Data
";
#[test]
fn a_debian_host_reads_whole() {
let Parsed::Reading(reading) = parse(DEBIAN) else {
panic!("a Linux host is read");
};
assert_eq!(
reading.system,
Some(SystemInfo {
host_name: "VM236A61A0D4D4D22".into(),
arch: "x86_64".into(),
os: "Debian GNU/Linux 12 (bookworm)".into(),
cpu_model: "AMD EPYC-Milan Processor".into(),
})
);
assert_eq!(reading.uptime, Some(Duration::from_secs_f64(5359162.33)));
assert_eq!(
reading.cpu,
CpuTimes {
busy: 4705 + 356 + 584 + 158 + 77,
idle: 3699176 + 2311,
}
);
assert_eq!(reading.cores.len(), 1);
assert_eq!(reading.memory.total, 996608 * 1024);
assert_eq!(reading.memory.used, (996608 - 565480) * 1024);
assert_eq!(reading.memory.swap_used, (1048572 - 996144) * 1024);
let names: Vec<_> = reading.interfaces.iter().map(|i| i.name.as_str()).collect();
assert_eq!(names, ["lo", "eth0", "docker0"]);
assert_eq!(reading.interfaces[1].received, 987654321);
assert_eq!(reading.interfaces[1].transmitted, 123456789);
// wg0's route covers half the addresses only.
assert_eq!(reading.default_route.as_deref(), Some("eth0"));
let disks = reading.disks.expect("asked for");
let mounts: Vec<_> = disks.iter().map(|d| d.mount.as_str()).collect();
assert_eq!(mounts, ["/", "/boot/efi", "/mnt/My Data"]);
assert_eq!(disks[0].used, 3240180 * 1024);
}
#[test]
fn an_old_kernel_without_mem_available_counts_free_buffers_and_cache() {
let memory = parse_memory(&[
"MemTotal: 1000 kB",
"MemFree: 100 kB",
"Buffers: 50 kB",
"Cached: 250 kB",
]);
assert_eq!(memory.used, 600 * 1024);
assert_eq!(memory.swap_total, 0);
}
#[test]
fn a_host_without_proc_says_what_it_is() {
assert_eq!(
parse("@@unsupported\nDarwin\n"),
Parsed::Unsupported(Some("Darwin".into()))
);
// cmd.exe has nothing to say to `sh -c`.
assert_eq!(parse(""), Parsed::Unsupported(None));
}
/// Every way a reading can be asked for.
fn commands() -> Vec<(Parts, String)> {
[(false, false), (false, true), (true, false), (true, true)]
.into_iter()
.map(|(system, disks)| {
let parts = Parts { system, disks };
(parts, command(parts))
})
.collect()
}
fn script(command: &str) -> &str {
command
.strip_prefix("sh -c '")
.and_then(|rest| rest.strip_suffix('\''))
.expect("one single-quoted script")
}
#[test]
fn the_commands_survive_any_login_shell() {
for (_, command) in commands() {
// Inside the single quotes there is nothing for the login shell.
let script = script(&command);
assert!(!script.contains('\''), "{command}");
assert!(!script.contains('!'), "{command}");
assert!(!script.contains('\n'), "{command}");
}
}
#[test]
fn a_reading_asks_only_for_its_parts() {
let light = command(Parts {
system: false,
disks: false,
});
assert!(light.contains("@@stat"));
assert!(!light.contains("@@host"));
assert!(!light.contains("@@df"));
// The load starts no process of its own.
for program in ["cat ", "grep ", "head ", "uname -n", "df "] {
assert!(!light.contains(program), "{program} in {light}");
}
let full = command(Parts {
system: true,
disks: true,
});
assert!(full.contains("@@host") && full.contains("@@df"));
}
/// The script run for real by `sh`, against `/proc` files laid out in a
/// directory of the test's own: the built-ins must read what `cat` and
/// `grep` would have.
#[cfg(unix)]
#[test]
fn the_script_reads_proc_with_shell_built_ins() {
let directory = tempfile::tempdir().unwrap();
let proc = directory.path().join("proc");
std::fs::create_dir_all(proc.join("net")).unwrap();
let files = [
("uptime", "5359162.33 5293480.12\n"),
(
"cpuinfo",
"processor\t: 0\nmodel name\t: AMD EPYC-Milan Processor\nflags\t: fpu\n",
),
(
"stat",
"cpu 4705 356 584 3699176 2311 0 158 77 0 0\n\
cpu0 4705 356 584 3699176 2311 0 158 77 0 0\n\
intr 123 4 5 6\nctxt 999\ncpu9 1 1 1 1 1 1 1 1 0 0\n",
),
(
"meminfo",
"MemTotal: 996608 kB\nMemFree: 120000 kB\n\
MemAvailable: 565480 kB\nBuffers: 40000 kB\n\
Cached: 400000 kB\nSwapCached: 0 kB\n\
SwapTotal: 1048572 kB\nSwapFree: 996144 kB\n\
MemTotal: 1 kB\n",
),
(
"net/route",
"Iface\tDestination\tGateway\tFlags\tRefCnt\tUse\tMetric\tMask\n\
eth0\t00000000\t0101A8C0\t0003\t0\t0\t100\t00000000\n",
),
(
"net/dev",
"Inter-| Receive | Transmit\n face |bytes packets|bytes packets\n\
\x20 lo: 10 1 0 0 0 0 0 0 10 1 0 0 0 0 0 0\n\
\x20 eth0: 987654321 1000 0 0 0 0 0 0 123456789 900 0 0 0 0 0 0\n",
),
];
for (name, content) in files {
std::fs::write(proc.join(name), content).unwrap();
}
let full = command(Parts {
system: true,
disks: false,
});
let script = script(&full).replace("/proc/", &format!("{}/", proc.display()));
let output = std::process::Command::new("sh")
.args(["-c", &script])
.output()
.expect("sh runs");
let output = String::from_utf8(output.stdout).unwrap();
let Parsed::Reading(reading) = parse(&output) else {
panic!("read as Linux: {output}");
};
let system = reading.system.expect("asked for");
assert_eq!(system.cpu_model, "AMD EPYC-Milan Processor");
assert_eq!(reading.uptime, Some(Duration::from_secs_f64(5359162.33)));
// The cpu lines at the top, and nothing after the first other line.
assert_eq!(reading.cores.len(), 1);
assert_eq!(reading.cpu.idle, 3699176 + 2311);
// Up to SwapFree, and no further.
assert_eq!(reading.memory.total, 996608 * 1024);
assert_eq!(reading.memory.used, (996608 - 565480) * 1024);
assert_eq!(reading.memory.swap_used, (1048572 - 996144) * 1024);
let eth0 = &reading.interfaces[1];
assert_eq!(
(eth0.name.as_str(), eth0.received, eth0.transmitted),
("eth0", 987654321, 123456789)
);
assert_eq!(reading.default_route.as_deref(), Some("eth0"));
assert_eq!(reading.disks, None, "not asked for");
}
#[cfg(unix)]
#[test]
fn every_command_is_valid_sh() {
for (parts, command) in commands() {
let status = std::process::Command::new("sh")
.args(["-n", "-c", script(&command)])
.status()
.expect("sh runs");
assert!(status.success(), "{parts:?}: {command}");
}
}
}