6 Commits

Author SHA1 Message Date
Gilles Soulier 55e68189d3 fix(smart v0.1.10): extraction contrôleur NVMe — rfind au lieu de split
split('n').next() sur "nvme0n1" retourne "" (chaîne vide avant le premier 'n')
→ smartctl -a -j /dev/ (chemin invalide, échec silencieux, aucun SMART collecté)

Correction : rfind('n') trouve le dernier séparateur namespace (nvme0[n]1)
et n[..pos] donne le nom du contrôleur correct (nvme0, nvme10, etc.)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 07:34:45 +02:00
Gilles Soulier db6fc65ee1 fix(v0.1.9): détection IP/interface — filtre VPN WireGuard par flags kernel
- get_local_ip: construit d'abord la liste des IPs physiques (getifaddrs
  avec IFF_POINTOPOINT exclu + type=1 ARPHRD_ETHER requis), puis vérifie
  que l'IP choisie par le UDP-connect-trick en fait partie → évite de
  retourner une IP VPN même quand le trafic y est routé
- is_physical: remplace le filtrage par préfixe de nom par type kernel
  /sys/class/net/<iface>/type == 1 (Ethernet/WiFi) ; exclut WireGuard
  (type 65534), tunnels et autres interfaces virtuelles nommées librement

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 07:28:14 +02:00
Gilles Soulier 1002a6be68 fix: polices woff2 invalides + debounce ResizeObserver config
- jetbrains-mono.woff2 et share-tech-mono.woff2 étaient des fichiers HTML
  (pages 404 téléchargées par erreur) → remplacés par les vrais binaires wOF2
- JetBrains Mono : fichiers séparés regular/bold (400 et 700)
- ResizeObserver popup détail : debounce 600ms pour éviter 50+ PUT /api/config
  lors d'un resize

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 07:14:11 +02:00
Gilles Soulier 017d7bb1bb fix(smart v0.1.8): NVMe — contrôleur correct + flag -a pour attributs complets
- /sys/block expose nvme0n1 (namespace), mais smartctl a besoin du contrôleur
  nvme0 → déduplication via HashSet pour éviter les doublons nvme0n1/nvme0
- smartctl -j → smartctl -a -j pour inclure nvme_smart_health_information_log
  (sans -a, le log de santé NVMe n'est pas dans la sortie JSON)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 07:07:45 +02:00
Gilles Soulier 5ee8b66464 feat(v0.1.7): port iperf3 configurable + iperf3 docker sur port 5202
- config.toml: nouveau champ [server] iperf3_port (défaut 5201)
- network_info: iperf3 -p <port> utilise le port configuré
- docker-compose: iperf3 exposé sur 5202 (5201 occupé par linux_benchtools)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 06:47:15 +02:00
Gilles Soulier c238e9f2b8 fix: supprimer service iperf3 — port 5201 déjà occupé par linux_benchtools
Un container iperf3 (linux_benchtools_iperf3) tourne depuis 4 mois sur le
même hôte. L'agent se connecte à l'IP du serveur:5201 qui résout vers ce
container existant.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 06:43:06 +02:00
13 changed files with 104 additions and 26 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "nanometrics-agent"
version = "0.1.6"
version = "0.1.10"
edition = "2021"
[lib]
+4
View File
@@ -13,8 +13,12 @@ pub struct Config {
pub struct ServerConfig {
pub ip: String,
pub port: u16,
#[serde(default = "default_iperf3_port")]
pub iperf3_port: u16,
}
fn default_iperf3_port() -> u16 { 5201 }
#[derive(Deserialize, Debug, Clone, Default)]
pub struct ProtocolsConfig {
#[serde(default)]
+49 -5
View File
@@ -10,22 +10,66 @@ extern "C" fn handle_signal(_: libc::c_int) {
RUNNING.store(false, Ordering::Relaxed);
}
fn physical_ipv4_addrs() -> Vec<String> {
let mut result = Vec::new();
unsafe {
let mut ifap = std::ptr::null_mut::<libc::ifaddrs>();
if libc::getifaddrs(&mut ifap) != 0 { return result; }
let mut ifa = ifap;
while !ifa.is_null() {
let flags = (*ifa).ifa_flags as i32;
let up = flags & libc::IFF_UP as i32 != 0;
let loopback = flags & libc::IFF_LOOPBACK as i32 != 0;
let pointop = flags & libc::IFF_POINTOPOINT as i32 != 0;
if !up || loopback || pointop { ifa = (*ifa).ifa_next; continue; }
let name = std::ffi::CStr::from_ptr((*ifa).ifa_name)
.to_string_lossy().into_owned();
// Type 1 = ARPHRD_ETHER (Ethernet + WiFi), exclut WireGuard (65534), tunnels, etc.
let itype: u32 = std::fs::read_to_string(
format!("/sys/class/net/{}/type", name))
.ok().and_then(|s| s.trim().parse().ok()).unwrap_or(0);
if itype != 1 { ifa = (*ifa).ifa_next; continue; }
// Exclut bridges et interfaces Docker par nom
let is_virtual = name.starts_with("br-") || name.starts_with("docker")
|| name.starts_with("virbr") || name.starts_with("veth");
if is_virtual { ifa = (*ifa).ifa_next; continue; }
if let Some(addr) = (*ifa).ifa_addr.as_ref() {
if addr.sa_family as i32 == libc::AF_INET {
let sin = addr as *const _ as *const libc::sockaddr_in;
let b = (*sin).sin_addr.s_addr.to_ne_bytes();
result.push(format!("{}.{}.{}.{}", b[0], b[1], b[2], b[3]));
}
}
ifa = (*ifa).ifa_next;
}
libc::freeifaddrs(ifap);
}
result
}
fn get_local_ip(server_ip: &str) -> String {
let physical = physical_ipv4_addrs();
use std::net::UdpSocket;
// Try server IP first (always reachable), then internet fallback
for target in &[format!("{}:80", server_ip), "8.8.8.8:80".to_string()] {
if let Ok(s) = UdpSocket::bind("0.0.0.0:0") {
if s.connect(target.as_str()).is_ok() {
if let Ok(addr) = s.local_addr() {
let ip = addr.ip().to_string();
if ip != "0.0.0.0" {
// N'accepte que si c'est une vraie interface physique
if ip != "0.0.0.0" && physical.contains(&ip) {
return ip;
}
}
}
}
}
"0.0.0.0".to_string()
// Fallback : première IP physique disponible
physical.into_iter().next().unwrap_or_else(|| "0.0.0.0".to_string())
}
fn apply_config_update(cfg: &mut config::Config, data: &[u8]) {
@@ -86,7 +130,7 @@ fn main() {
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);
let ni = metrics::network_info::collect(&cfg.server.ip, cfg.server.iperf3_port);
if !ni.is_empty() { startup_net = Some(ni); }
}
if cfg.metrics.hardware_info.udp || cfg.metrics.hardware_info.mqtt {
@@ -116,7 +160,7 @@ fn main() {
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);
let ni = metrics::network_info::collect(&cfg.server.ip, cfg.server.iperf3_port);
if !ni.is_empty() { daily_net = Some(ni); }
}
if cfg.metrics.hardware_info.udp || cfg.metrics.hardware_info.mqtt {
+12 -9
View File
@@ -26,11 +26,13 @@ pub fn current_yday() -> 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
// Type 1 = ARPHRD_ETHER (Ethernet + WiFi). WireGuard = 65534, tunnels = autres.
let itype: u32 = std::fs::read_to_string(format!("/sys/class/net/{}/type", name))
.ok().and_then(|s| s.trim().parse().ok()).unwrap_or(0);
if itype != 1 { return false; }
// Exclut bridges et interfaces Docker par nom (type 1 aussi)
!name.starts_with("br-") && !name.starts_with("docker")
&& !name.starts_with("virbr") && !name.starts_with("veth")
}
fn read_sysfs(iface: &str, file: &str) -> Option<String> {
@@ -56,12 +58,13 @@ fn wol_status(name: &str) -> Option<bool> {
None
}
fn iperf_mbps(server_ip: &str) -> Option<f64> {
fn iperf_mbps(server_ip: &str, port: u16) -> Option<f64> {
std::process::Command::new("which").arg("iperf3")
.output().ok()
.filter(|o| o.status.success())?;
let port_str = port.to_string();
let out = std::process::Command::new("iperf3")
.args(["-c", server_ip, "-J", "-t", "5", "-P", "1"])
.args(["-c", server_ip, "-p", &port_str, "-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()?;
@@ -69,7 +72,7 @@ fn iperf_mbps(server_ip: &str) -> Option<f64> {
Some((bps / 1_000_000.0 * 10.0).round() / 10.0)
}
pub fn collect(server_ip: &str) -> Vec<crate::payload::NetworkInterface> {
pub fn collect(server_ip: &str, iperf3_port: u16) -> Vec<crate::payload::NetworkInterface> {
let entries = match std::fs::read_dir("/sys/class/net") {
Ok(e) => e,
Err(_) => return vec![],
@@ -82,7 +85,7 @@ pub fn collect(server_ip: &str) -> Vec<crate::payload::NetworkInterface> {
ifaces.sort();
if ifaces.is_empty() { return vec![]; }
let iperf = iperf_mbps(server_ip);
let iperf = iperf_mbps(server_ip, iperf3_port);
ifaces.iter().map(|name| {
let speed = read_sysfs(name, "speed")
+20 -3
View File
@@ -89,15 +89,32 @@ pub fn collect() -> Option<Vec<crate::payload::SmartMetrics>> {
.flatten()
.flatten()
.map(|e| e.file_name().into_string().unwrap_or_default())
.filter(|n| n.starts_with("sd") || n.starts_with("nvme"))
.map(|n| format!("/dev/{}", n))
.filter_map(|n| {
if n.starts_with("sd") {
Some(format!("/dev/{}", n))
} else if n.starts_with("nvme") {
// /sys/block a nvme0n1 (namespace) → extraire le contrôleur nvme0
// rfind('n') trouve le dernier 'n' séparateur namespace, ex: nvme0[n]1
let ctrl = if let Some(pos) = n.rfind('n')
.filter(|&p| p >= 5 && n[p+1..].chars().all(|c| c.is_ascii_digit())) {
&n[..pos]
} else {
&n // déjà un nom de contrôleur (rare)
};
Some(format!("/dev/{}", ctrl))
} else {
None
}
})
.collect::<std::collections::HashSet<_>>()
.into_iter()
.collect();
devs.sort();
let mut results = Vec::new();
for dev in &devs {
let Ok(output) = std::process::Command::new("smartctl")
.args(["-j", dev])
.args(["-a", "-j", dev])
.output() else { continue };
let json = String::from_utf8_lossy(&output.stdout);
if let Ok(metrics) = parse_json(&json) {
+7 -1
View File
@@ -15,7 +15,13 @@
@font-face {
font-family: 'JetBrains Mono';
src: url('../fonts/jetbrains-mono.woff2') format('woff2');
font-weight: 400 700;
font-weight: 400;
font-display: swap;
}
@font-face {
font-family: 'JetBrains Mono';
src: url('../fonts/jetbrains-mono-bold.woff2') format('woff2');
font-weight: 700;
font-display: swap;
}
@font-face {
Binary file not shown.
Binary file not shown.
Binary file not shown.
+9 -5
View File
@@ -2,6 +2,7 @@ const Popups = (() => {
let _currentAgentId = null;
let _agentCfgData = null;
let _resizeObs = null;
let _resizeTimer = null;
// ══ POPUP DÉTAIL ══
async function showDetail(agentId) {
@@ -214,11 +215,14 @@ const Popups = (() => {
if (_resizeObs) _resizeObs.disconnect();
const pd = document.getElementById('popup-detail');
_resizeObs = new ResizeObserver(() => {
API.putServerConfig({
...App.serverConfig,
popup_detail_w: pd.offsetWidth,
popup_detail_h: pd.offsetHeight,
}).catch(() => {});
clearTimeout(_resizeTimer);
_resizeTimer = setTimeout(() => {
API.putServerConfig({
...App.serverConfig,
popup_detail_w: pd.offsetWidth,
popup_detail_h: pd.offsetHeight,
}).catch(() => {});
}, 600);
});
_resizeObs.observe(pd);
Binary file not shown.
Binary file not shown.
+2 -2
View File
@@ -38,8 +38,8 @@ services:
restart: unless-stopped
command: ["-s"]
ports:
- "5201:5201/tcp"
- "5201:5201/udp"
- "5202:5201/tcp"
- "5202:5201/udp"
volumes:
nanometrics_data: