diff --git a/agent/Cargo.lock b/agent/Cargo.lock index e5cd6c3..9e45275 100644 --- a/agent/Cargo.lock +++ b/agent/Cargo.lock @@ -248,7 +248,7 @@ dependencies = [ [[package]] name = "nanometrics-agent" -version = "0.1.5" +version = "0.1.6" dependencies = [ "libc", "rumqttc", diff --git a/agent/Cargo.toml b/agent/Cargo.toml index a965f48..c56b5e6 100644 --- a/agent/Cargo.toml +++ b/agent/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nanometrics-agent" -version = "0.1.5" +version = "0.1.6" edition = "2021" [lib] diff --git a/agent/src/config.rs b/agent/src/config.rs index e3bb079..ec1f8da 100644 --- a/agent/src/config.rs +++ b/agent/src/config.rs @@ -90,6 +90,26 @@ pub struct MetricsConfig { pub temperature: MetricProto, #[serde(default)] pub smart: MetricProto, + #[serde(default)] + pub network_info: SlowMetricProto, + #[serde(default)] + pub hardware_info: SlowMetricProto, + #[serde(default = "default_slow_time")] + pub slow_daily_time: String, +} + +fn default_slow_time() -> String { "03:00".to_string() } + +#[derive(Deserialize, Debug, Clone)] +pub struct SlowMetricProto { + #[serde(default = "default_true")] + pub udp: bool, + #[serde(default)] + pub mqtt: bool, +} + +impl Default for SlowMetricProto { + fn default() -> Self { Self { udp: true, mqtt: false } } } #[derive(Deserialize, Debug, Clone, Default)] diff --git a/agent/src/main.rs b/agent/src/main.rs index c6218db..f53c1fd 100644 --- a/agent/src/main.rs +++ b/agent/src/main.rs @@ -72,6 +72,27 @@ fn main() { let mut first_medium = true; let mut first_slow = true; + // Scheduler métriques lentes (startup + 1×/jour à l'heure configurée) + let slow_time: (u32, u32) = { + let parts: Vec<&str> = cfg.metrics.slow_daily_time.splitn(2, ':').collect(); + let h = parts.first().and_then(|s| s.parse().ok()).unwrap_or(3u32); + let m = parts.get(1).and_then(|s| s.parse().ok()).unwrap_or(0u32); + (h, m) + }; + let mut slow_daily_done = false; + let mut slow_last_yday = metrics::network_info::current_yday().wrapping_sub(1); + + // Collecte immédiate au démarrage + let mut startup_net: Option> = None; + let mut startup_hw: Option = None; + if cfg.metrics.network_info.udp || cfg.metrics.network_info.mqtt { + let ni = metrics::network_info::collect(&cfg.server.ip); + if !ni.is_empty() { startup_net = Some(ni); } + } + if cfg.metrics.hardware_info.udp || cfg.metrics.hardware_info.mqtt { + startup_hw = metrics::hardware::collect(); + } + while RUNNING.load(Ordering::Relaxed) { let now = Instant::now(); @@ -82,11 +103,35 @@ fn main() { sys.refresh_cpu_usage(); sys.refresh_memory(); + // Métriques lentes quotidiennes + let cur_yday = metrics::network_info::current_yday(); + if cur_yday != slow_last_yday { + slow_last_yday = cur_yday; + slow_daily_done = false; + } + let mut daily_net: Option> = None; + let mut daily_hw: Option = None; + if !slow_daily_done { + let (ch, cm) = metrics::network_info::current_hhmm(); + if ch == slow_time.0 && cm == slow_time.1 { + slow_daily_done = true; + if cfg.metrics.network_info.udp || cfg.metrics.network_info.mqtt { + let ni = metrics::network_info::collect(&cfg.server.ip); + if !ni.is_empty() { daily_net = Some(ni); } + } + if cfg.metrics.hardware_info.udp || cfg.metrics.hardware_info.mqtt { + daily_hw = metrics::hardware::collect(); + } + } + } + let mut m = payload::AgentMetrics { hostname: hostname.clone(), ip: ip.clone(), status: "online".to_string(), version: env!("CARGO_PKG_VERSION").to_string(), + network_info: daily_net.or_else(|| startup_net.take()), + hardware_info: daily_hw.or_else(|| startup_hw.take()), ..Default::default() }; diff --git a/agent/src/metrics/hardware.rs b/agent/src/metrics/hardware.rs new file mode 100644 index 0000000..1c110d4 --- /dev/null +++ b/agent/src/metrics/hardware.rs @@ -0,0 +1,72 @@ +fn run_dmidecode(type_num: u8) -> String { + std::process::Command::new("dmidecode") + .args(["-t", &type_num.to_string()]) + .output() + .map(|o| String::from_utf8_lossy(&o.stdout).into_owned()) + .unwrap_or_default() +} + +fn extract_field(text: &str, key: &str) -> Option { + for line in text.lines() { + let t = line.trim(); + if t.starts_with(key) { + let val = t[key.len()..].trim().trim_start_matches(':').trim(); + if !val.is_empty() && val != "Not Specified" && val != "Unknown" + && val != "To Be Filled By O.E.M." { + return Some(val.to_string()); + } + } + } + None +} + +pub fn is_available() -> bool { + std::process::Command::new("which") + .arg("dmidecode") + .output() + .map(|o| o.status.success()) + .unwrap_or(false) +} + +pub fn collect() -> Option { + if !is_available() { return None; } + + let board = run_dmidecode(2); // Baseboard + let cpu = run_dmidecode(4); // Processor + let mem = run_dmidecode(17); // Memory Device + + let mut slots_total: i64 = 0; + let mut slots_used: i64 = 0; + let mut ram_type: Option = None; + let mut ram_speed: Option = None; + + for block in mem.split("\n\n") { + if !block.contains("Memory Device") { continue; } + slots_total += 1; + if let Some(size) = extract_field(block, "Size") { + if !size.contains("No Module") && size != "0" { + slots_used += 1; + } + } + if ram_type.is_none() { + ram_type = extract_field(block, "Type") + .filter(|t| t != "Unknown" && t != "Other"); + } + if ram_speed.is_none() { + if let Some(spd) = extract_field(block, "Speed") { + ram_speed = spd.split_whitespace().next() + .and_then(|s| s.parse().ok()); + } + } + } + + Some(crate::payload::HardwareInfo { + motherboard_vendor: extract_field(&board, "Manufacturer"), + motherboard_model: extract_field(&board, "Product Name"), + cpu_model: extract_field(&cpu, "Version").or_else(|| extract_field(&cpu, "Family")), + ram_type, + ram_speed_mhz: ram_speed, + ram_slots_used: if slots_total > 0 { Some(slots_used) } else { None }, + ram_slots_total: if slots_total > 0 { Some(slots_total) } else { None }, + }) +} diff --git a/agent/src/metrics/mod.rs b/agent/src/metrics/mod.rs index 9eb031d..820869c 100644 --- a/agent/src/metrics/mod.rs +++ b/agent/src/metrics/mod.rs @@ -1,7 +1,9 @@ pub mod cpu; pub mod disk; +pub mod hardware; pub mod memory; pub mod network; +pub mod network_info; pub mod smart; pub mod temperature; pub mod uptime; diff --git a/agent/src/metrics/network_info.rs b/agent/src/metrics/network_info.rs new file mode 100644 index 0000000..db3a5c4 --- /dev/null +++ b/agent/src/metrics/network_info.rs @@ -0,0 +1,102 @@ +use std::mem::MaybeUninit; + +fn local_hhmm() -> (u32, u32) { + let now = std::time::SystemTime::now() + .duration_since(std::time::SystemTime::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i64; + let mut tm = MaybeUninit::::uninit(); + unsafe { libc::localtime_r(&now, tm.as_mut_ptr()) }; + let tm = unsafe { tm.assume_init() }; + (tm.tm_hour as u32, tm.tm_min as u32) +} + +pub fn current_hhmm() -> (u32, u32) { + local_hhmm() +} + +pub fn current_yday() -> u32 { + let now = std::time::SystemTime::now() + .duration_since(std::time::SystemTime::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i64; + let mut tm = MaybeUninit::::uninit(); + unsafe { libc::localtime_r(&now, tm.as_mut_ptr()) }; + unsafe { tm.assume_init() }.tm_yday as u32 +} + +fn is_physical(name: &str) -> bool { + if name == "lo" { return false; } + for prefix in &["veth", "docker", "br-", "virbr", "vir", "tun", "tap", "dummy", "bond"] { + if name.starts_with(prefix) { return false; } + } + true +} + +fn read_sysfs(iface: &str, file: &str) -> Option { + std::fs::read_to_string(format!("/sys/class/net/{}/{}", iface, file)) + .ok() + .map(|s| s.trim().to_string()) +} + +fn is_wifi(name: &str) -> bool { + std::path::Path::new(&format!("/sys/class/net/{}/wireless", name)).exists() +} + +fn wol_status(name: &str) -> Option { + let out = std::process::Command::new("ethtool").arg(name).output().ok()?; + let text = String::from_utf8_lossy(&out.stdout); + for line in text.lines() { + let t = line.trim(); + if t.starts_with("Wake-on:") { + let val = t.split(':').nth(1)?.trim().to_string(); + return Some(val != "d" && !val.is_empty()); + } + } + None +} + +fn iperf_mbps(server_ip: &str) -> Option { + std::process::Command::new("which").arg("iperf3") + .output().ok() + .filter(|o| o.status.success())?; + let out = std::process::Command::new("iperf3") + .args(["-c", server_ip, "-J", "-t", "5", "-P", "1"]) + .output().ok()?; + let json = String::from_utf8_lossy(&out.stdout); + let v: serde_json::Value = serde_json::from_str(&json).ok()?; + let bps = v["end"]["sum_received"]["bits_per_second"].as_f64()?; + Some((bps / 1_000_000.0 * 10.0).round() / 10.0) +} + +pub fn collect(server_ip: &str) -> Vec { + let entries = match std::fs::read_dir("/sys/class/net") { + Ok(e) => e, + Err(_) => return vec![], + }; + let mut ifaces: Vec = entries + .flatten() + .map(|e| e.file_name().into_string().unwrap_or_default()) + .filter(|n| is_physical(n)) + .collect(); + ifaces.sort(); + if ifaces.is_empty() { return vec![]; } + + let iperf = iperf_mbps(server_ip); + + ifaces.iter().map(|name| { + let speed = read_sysfs(name, "speed") + .and_then(|s| s.parse::().ok()) + .filter(|&v| v > 0); + let mac = read_sysfs(name, "address").unwrap_or_default(); + let wifi = is_wifi(name); + crate::payload::NetworkInterface { + name: name.clone(), + if_type: if wifi { "wifi".to_string() } else { "ethernet".to_string() }, + speed_mbps: speed, + mac, + wol: if wifi { None } else { wol_status(name) }, + iperf_mbps: iperf, + } + }).collect() +} diff --git a/agent/src/payload.rs b/agent/src/payload.rs index d33fa57..0268a47 100644 --- a/agent/src/payload.rs +++ b/agent/src/payload.rs @@ -19,6 +19,29 @@ pub struct AgentMetrics { pub network_tx: Option, pub temperature: Option, pub smart: Option>, + pub network_info: Option>, + pub hardware_info: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default)] +pub struct NetworkInterface { + pub name: String, + pub if_type: String, + pub speed_mbps: Option, + pub mac: String, + pub wol: Option, + pub iperf_mbps: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default)] +pub struct HardwareInfo { + pub motherboard_vendor: Option, + pub motherboard_model: Option, + pub cpu_model: Option, + pub ram_type: Option, + pub ram_speed_mhz: Option, + pub ram_slots_used: Option, + pub ram_slots_total: Option, } #[derive(Serialize, Deserialize, Debug, Clone)] diff --git a/deploy/install.sh b/deploy/install.sh index b03c7f9..7df67fb 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -31,7 +31,7 @@ echo "" # ── 1. Dépendances système ───────────────────────────────────────────────────── PKGS_NEEDED=() -for pkg in curl python3 smartmontools ethtool; do +for pkg in curl python3 smartmontools ethtool iperf3 dmidecode; do dpkg -l "$pkg" 2>/dev/null | grep -q '^ii' || PKGS_NEEDED+=("$pkg") done