feat(agent v0.1.6): métriques réseau enrichies + hardware dmidecode

- Nouveaux types payload: NetworkInterface, HardwareInfo
- Config: slow_daily_time (HH:MM), network_info, hardware_info
- Module network_info: interfaces locales, type ETH/WIFI, speed, MAC, WoL, iperf3
- Module hardware: dmidecode (carte mère, CPU, slots RAM, type/vitesse)
- Scheduler: collecte au démarrage + 1×/jour à l'heure configurée
- install.sh: ajout iperf3, dmidecode dans paquets

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Gilles Soulier
2026-05-23 06:16:02 +02:00
parent 49626ddb9e
commit 0430c0f2a8
9 changed files with 267 additions and 3 deletions
+1 -1
View File
@@ -248,7 +248,7 @@ dependencies = [
[[package]]
name = "nanometrics-agent"
version = "0.1.5"
version = "0.1.6"
dependencies = [
"libc",
"rumqttc",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "nanometrics-agent"
version = "0.1.5"
version = "0.1.6"
edition = "2021"
[lib]
+20
View File
@@ -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)]
+45
View File
@@ -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<Vec<payload::NetworkInterface>> = None;
let mut startup_hw: Option<payload::HardwareInfo> = 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<Vec<payload::NetworkInterface>> = None;
let mut daily_hw: Option<payload::HardwareInfo> = 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()
};
+72
View File
@@ -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<String> {
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<crate::payload::HardwareInfo> {
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<String> = None;
let mut ram_speed: Option<i64> = 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 },
})
}
+2
View File
@@ -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;
+102
View File
@@ -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::<libc::tm>::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::<libc::tm>::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<String> {
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<bool> {
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<f64> {
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<crate::payload::NetworkInterface> {
let entries = match std::fs::read_dir("/sys/class/net") {
Ok(e) => e,
Err(_) => return vec![],
};
let mut ifaces: Vec<String> = 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::<i64>().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()
}
+23
View File
@@ -19,6 +19,29 @@ pub struct AgentMetrics {
pub network_tx: Option<u64>,
pub temperature: Option<f32>,
pub smart: Option<Vec<SmartMetrics>>,
pub network_info: Option<Vec<NetworkInterface>>,
pub hardware_info: Option<HardwareInfo>,
}
#[derive(Serialize, Deserialize, Debug, Clone, Default)]
pub struct NetworkInterface {
pub name: String,
pub if_type: String,
pub speed_mbps: Option<i64>,
pub mac: String,
pub wol: Option<bool>,
pub iperf_mbps: Option<f64>,
}
#[derive(Serialize, Deserialize, Debug, Clone, Default)]
pub struct HardwareInfo {
pub motherboard_vendor: Option<String>,
pub motherboard_model: Option<String>,
pub cpu_model: Option<String>,
pub ram_type: Option<String>,
pub ram_speed_mhz: Option<i64>,
pub ram_slots_used: Option<i64>,
pub ram_slots_total: Option<i64>,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
+1 -1
View File
@@ -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