use crate::config::{ConnectionMode, ResolvedServer};
use crate::ssh::client::build_ssh_args;
use anyhow::Result;
use std::process::Command;
const PROBE_BASE: &str = concat!(
"uname -r; ",
"awk '/^model name/{sub(/.*: /,\"\"); print; exit}' /proc/cpuinfo; ",
"nproc; ",
"awk -F= '/^PRETTY_NAME=/{gsub(/\"/,\"\",$2); print $2; exit}' /etc/os-release 2>/dev/null || echo unknown; ",
"uptime | awk -F'load average:' '{print $2}' | xargs; ",
"free -b | awk '/^Mem/{printf \"%.0f %.0f\\n\", $3/$2*100, $2}'; ",
"df -B1 / | awk 'NR==2{printf \"%.0f %.0f\\n\", $3/$2*100, $2}'",
);
fn build_probe_cmd(extra_filesystems: &[String]) -> String {
if extra_filesystems.is_empty() {
return PROBE_BASE.to_string();
}
let mut cmd = PROBE_BASE.to_string();
for fs in extra_filesystems {
cmd.push_str(&format!(
"; df -B1 {fs} 2>/dev/null \
| awk 'NR==2{{printf \"%.0f %.0f\\n\", $3/$2*100, $2; found=1}} \
END{{if(!found) print \"absent\"}}'"
));
}
cmd
}
#[derive(Debug, Clone)]
pub struct FsUsage {
pub pct: u8,
pub total_gb: f32,
}
#[derive(Debug, Clone)]
pub struct FsEntry {
pub mountpoint: String,
pub usage: Option<FsUsage>,
}
#[derive(Debug, Clone)]
pub struct ProbeResult {
pub kernel: String,
pub cpu_model: String,
pub cpu_cores: u32,
pub os_name: String,
pub load: String,
pub ram_pct: u8,
pub ram_total_gb: f32,
pub disk_pct: u8,
pub disk_total_gb: f32,
pub extra_fs: Vec<FsEntry>,
}
#[derive(Debug, Default, Clone)]
pub enum ProbeState {
#[default]
Idle,
Running,
Done(ProbeResult),
Error(String),
}
impl ProbeResult {
pub fn parse(raw: &str, extra_filesystems: &[String]) -> Result<Self> {
let lines: Vec<&str> = raw
.lines()
.map(str::trim)
.filter(|l| !l.is_empty())
.collect();
if lines.len() < 7 {
anyhow::bail!(
"sortie inattendue ({} lignes au lieu de 7) :\n{}",
lines.len(),
raw
);
}
let cpu_cores: u32 = lines[2]
.parse()
.map_err(|e| anyhow::anyhow!("cpu_cores: {}", e))?;
let os_name = lines[3].to_string();
let (ram_pct, ram_total_gb) = parse_pct_bytes(lines[5], "RAM")?;
let (disk_pct, disk_total_gb) = parse_pct_bytes(lines[6], "Disk")?;
let mut extra_fs = Vec::new();
for (i, mountpoint) in extra_filesystems.iter().enumerate() {
let usage = match lines.get(7 + i) {
Some(&"absent") | None => None,
Some(line) => {
let (pct, total_gb) = parse_pct_bytes(line, mountpoint)?;
Some(FsUsage { pct, total_gb })
}
};
extra_fs.push(FsEntry {
mountpoint: mountpoint.clone(),
usage,
});
}
Ok(ProbeResult {
kernel: lines[0].to_string(),
cpu_model: lines[1].to_string(),
cpu_cores,
os_name,
load: lines[4].to_string(),
ram_pct,
ram_total_gb,
disk_pct,
disk_total_gb,
extra_fs,
})
}
}
fn parse_pct_bytes(line: &str, label: &str) -> Result<(u8, f32)> {
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() < 2 {
anyhow::bail!("format {} inattendu : {:?}", label, line);
}
let pct: u8 = parts[0]
.parse()
.map_err(|e| anyhow::anyhow!("{} pct: {}", label, e))?;
let bytes: u64 = parts[1]
.parse()
.map_err(|e| anyhow::anyhow!("{} bytes: {}", label, e))?;
Ok((pct, bytes as f32 / 1_073_741_824.0))
}
pub fn probe(server: &ResolvedServer, mode: ConnectionMode) -> Result<ProbeResult> {
if mode == ConnectionMode::Wallix {
anyhow::bail!("Le diagnostic n'est pas disponible en mode Wallix");
}
let mut args = build_ssh_args(server, mode, false)?;
let destination = args
.pop()
.ok_or_else(|| anyhow::anyhow!("liste d'args SSH vide"))?;
args.push("-n".into()); args.push("-o".into());
args.push("ConnectTimeout=10".into());
args.push("-o".into());
args.push("BatchMode=yes".into());
args.push(destination);
let cmd = build_probe_cmd(&server.probe_filesystems);
args.push(cmd);
let output = Command::new("ssh").args(&args).output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("SSH probe échoué : {}", stderr.trim());
}
ProbeResult::parse(
&String::from_utf8_lossy(&output.stdout),
&server.probe_filesystems,
)
}
#[cfg(test)]
mod tests {
use super::*;
const SAMPLE: &str = "6.1.0-28-amd64\n\
Intel(R) Xeon(R) CPU E5-2670 0 @ 2.60GHz\n\
8\n\
Debian GNU/Linux 12 (bookworm)\n\
0.42, 0.38, 0.31\n\
67 16106127360\n\
23 499963174912\n";
#[test]
fn parse_valid() {
let r = ProbeResult::parse(SAMPLE, &[]).unwrap();
assert_eq!(r.kernel, "6.1.0-28-amd64");
assert_eq!(r.cpu_model, "Intel(R) Xeon(R) CPU E5-2670 0 @ 2.60GHz");
assert_eq!(r.cpu_cores, 8);
assert_eq!(r.os_name, "Debian GNU/Linux 12 (bookworm)");
assert_eq!(r.load, "0.42, 0.38, 0.31");
assert_eq!(r.ram_pct, 67);
assert!((r.ram_total_gb - 15.0).abs() < 1.0, "ram ~15 GB");
assert_eq!(r.disk_pct, 23);
assert!((r.disk_total_gb - 465.7).abs() < 1.0, "disk ~465 GB");
assert!(r.extra_fs.is_empty());
}
#[test]
fn parse_ignores_trailing_blank_lines() {
let with_blanks = format!("{}\n\n", SAMPLE);
assert!(ProbeResult::parse(&with_blanks, &[]).is_ok());
}
#[test]
fn parse_too_few_lines() {
let err = ProbeResult::parse("only one line\n", &[]).unwrap_err();
assert!(err.to_string().contains("lignes"));
}
#[test]
fn parse_bad_ram_pct() {
let bad = "6.1.0\nIntel\n8\nDebian 12\n0.1\nXX 16000000000\n23 500000000000\n";
assert!(ProbeResult::parse(bad, &[]).is_err());
}
#[test]
fn parse_bad_ram_bytes() {
let bad = "6.1.0\nIntel\n8\nDebian 12\n0.1\n67 not_a_number\n23 500000000000\n";
assert!(ProbeResult::parse(bad, &[]).is_err());
}
#[test]
fn parse_bad_disk_pct() {
let bad = "6.1.0\nIntel\n8\nDebian 12\n0.1\n67 16000000000\nXX 500000000000\n";
assert!(ProbeResult::parse(bad, &[]).is_err());
}
#[test]
fn parse_disk_single_column() {
let bad = "6.1.0\nIntel\n8\nDebian 12\n0.1\n67 16000000000\n23\n";
assert!(ProbeResult::parse(bad, &[]).is_err());
}
#[test]
fn parse_bad_cpu_cores() {
let bad = "6.1.0\nIntel\nnot_a_number\nDebian 12\n0.1\n67 16000000000\n23 500000000000\n";
assert!(ProbeResult::parse(bad, &[]).is_err());
}
#[test]
fn parse_extra_fs_present() {
let raw = format!("{}{}", SAMPLE, "45 107374182400\n30 53687091200\n");
let fs_list = vec!["/data".to_string(), "/var/log".to_string()];
let r = ProbeResult::parse(&raw, &fs_list).unwrap();
assert_eq!(r.extra_fs.len(), 2);
let data = &r.extra_fs[0];
assert_eq!(data.mountpoint, "/data");
let usage = data.usage.as_ref().expect("/data should be present");
assert_eq!(usage.pct, 45);
assert!((usage.total_gb - 100.0).abs() < 1.0, "expected ~100 GB");
let varlog = &r.extra_fs[1];
assert_eq!(varlog.mountpoint, "/var/log");
assert!(varlog.usage.is_some());
}
#[test]
fn parse_extra_fs_absent() {
let raw = format!("{}{}", SAMPLE, "45 107374182400\nabsent\n");
let fs_list = vec!["/data".to_string(), "/backup".to_string()];
let r = ProbeResult::parse(&raw, &fs_list).unwrap();
assert_eq!(r.extra_fs.len(), 2);
assert!(r.extra_fs[0].usage.is_some());
assert!(r.extra_fs[1].usage.is_none(), "/backup should be absent");
}
#[test]
fn parse_extra_fs_all_absent() {
let fs_list = vec!["/mnt/nas".to_string()];
let r = ProbeResult::parse(SAMPLE, &fs_list).unwrap();
assert_eq!(r.extra_fs.len(), 1);
assert!(r.extra_fs[0].usage.is_none(), "/mnt/nas should be absent");
}
#[test]
fn build_probe_cmd_no_extra() {
let cmd = build_probe_cmd(&[]);
assert!(cmd.contains("uname -r"));
assert!(!cmd.contains("df -B1 /data"));
}
#[test]
fn build_probe_cmd_with_extra() {
let fs = vec!["/data".to_string(), "/backup".to_string()];
let cmd = build_probe_cmd(&fs);
assert!(cmd.contains("df -B1 /data"));
assert!(cmd.contains("df -B1 /backup"));
assert!(cmd.contains("if(!found) print \"absent\""));
}
}