12 Commits
Author SHA1 Message Date
Gilles SoulierandClaude Sonnet 4.6 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 SoulierandClaude Sonnet 4.6 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 SoulierandClaude Sonnet 4.6 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 SoulierandClaude Sonnet 4.6 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 SoulierandClaude Sonnet 4.6 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 SoulierandClaude Sonnet 4.6 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
Gilles SoulierandClaude Sonnet 4.6 d7fe0004ad fix: iperf3 — build depuis Alpine ECR au lieu d'image communautaire Docker Hub
networkstatic/iperf3 n'est pas disponible sur ECR public (images officielles seulement).
Solution : Dockerfile.iperf3 basé sur alpine:latest + apk add iperf3.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 06:25:23 +02:00
Gilles SoulierandClaude Sonnet 4.6 0247cfaada chore: binaire agent v0.1.6 linux-arm64
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 06:24:19 +02:00
Gilles SoulierandClaude Sonnet 4.6 dcfba242d6 chore: binaire agent v0.1.6 linux-amd64
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 06:22:56 +02:00
Gilles SoulierandClaude Sonnet 4.6 ff6cf1cd5e feat: métriques réseau+hardware serveur+dashboard + API /agents/{id} + iperf3
Serveur:
- Modèles Go: NetworkInterface, HardwareInfo dans Agent + AgentMetrics
- DB: migrations network_info_json + hardware_info_json dans agents
- UpsertAgent: stocke les données lentes si présentes dans le payload
- GetAgents: désérialise network_info_json + hardware_info_json
- GET /api/agents/{id}: endpoint single agent
- docker-compose: service iperf3 (port 5201)

Dashboard:
- Popup détail: section RÉSEAU (tableau interfaces: type, vitesse, MAC, WoL, iperf3)
- Popup détail: section HARDWARE (carte mère, CPU, RAM slots/type/vitesse)
- CSS: .net-table/.net-row pour le tableau réseau
- Font-size global appliqué sur html root (au lieu de body)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 06:17:54 +02:00
Gilles SoulierandClaude Sonnet 4.6 0430c0f2a8 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>
2026-05-23 06:16:02 +02:00
Gilles SoulierandClaude Sonnet 4.6 49626ddb9e feat: RAM en Go dans popup + version agent dans install.sh
- Popup détail : valeur absolue RAM (Go) affichée à côté du %
- install.sh : bannière version agent plus visible à la fin

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 06:10:55 +02:00
24 changed files with 534 additions and 47 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.10"
edition = "2021"
[lib]
+24
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)]
@@ -90,6 +94,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)]
+92 -3
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]) {
@@ -72,6 +116,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, cfg.server.iperf3_port);
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 +147,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, cfg.server.iperf3_port);
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;
+105
View File
@@ -0,0 +1,105 @@
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 {
// 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> {
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, 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, "-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()?;
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, iperf3_port: u16) -> 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, iperf3_port);
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()
}
+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) {
+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)]
+14 -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 {
@@ -279,5 +285,12 @@ body{background:var(--bg-1);color:var(--ink-1);font-family:var(--font-ui);font-s
border-radius:6px;background:var(--bg-3);border:1px solid var(--border-1)}
.attr-ok{color:var(--ok)}
/* Réseau + Hardware */
.net-table{display:flex;flex-direction:column;gap:3px}
.net-row{display:grid;grid-template-columns:18px 1fr 56px 130px 90px 90px;
align-items:center;gap:8px;padding:6px 10px;
background:var(--bg-3);border-radius:6px;border:1px solid var(--border-1);
font-family:var(--font-terminal);font-size:10px;color:var(--ink-2)}
::-webkit-scrollbar{width:5px}::-webkit-scrollbar-track{background:var(--bg-1)}
::-webkit-scrollbar-thumb{background:var(--bg-4);border-radius:3px}
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1 -1
View File
@@ -109,7 +109,7 @@ const App = (() => {
document.documentElement.style.setProperty('--tile-min', _serverConfig.tile_min_width + 'px');
}
if (_serverConfig.font_size) {
document.body.style.fontSize = _serverConfig.font_size + 'px';
document.documentElement.style.fontSize = _serverConfig.font_size + 'px';
}
if (_serverConfig.popup_detail_w && _serverConfig.popup_detail_h) {
const pd = document.getElementById('popup-detail');
+54 -6
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) {
@@ -128,7 +129,10 @@ const Popups = (() => {
<div class="chart-card">
<div class="chart-header">
<div class="chart-label" style="color:var(--blue)"><i class="fa-solid fa-memory"></i>RAM</div>
<span class="chart-cur" style="color:var(--blue)">${Grid.fmtPct(metrics?.memory_used && metrics?.memory_total ? metrics.memory_used / metrics.memory_total * 100 : null)}</span>
<div style="display:flex;align-items:baseline;gap:5px">
<span class="chart-cur" style="color:var(--blue)">${Grid.fmtPct(metrics?.memory_used && metrics?.memory_total ? metrics.memory_used / metrics.memory_total * 100 : null)}</span>
<span style="font-family:var(--font-mono);font-size:10px;color:var(--ink-3)">${Grid.fmt(metrics?.memory_used)}</span>
</div>
</div>
<svg class="chart-svg" viewBox="0 0 200 52" preserveAspectRatio="none" id="det-mem-chart"></svg>
<div class="chart-axis"><span>30min</span><span>15min</span><span>now</span></div>
@@ -148,6 +152,47 @@ const Popups = (() => {
${smartBadges}
</div>
</div>
${(() => {
const ni = entry?.agent?.network_info;
if (!ni?.length) return '';
const wol = v => v == null ? '—' : v ? '<span style="color:var(--ok)">Oui</span>' : '<span style="color:var(--ink-4)">Non</span>';
const spd = v => v == null ? '—' : v >= 1000 ? '1 Gb' : v + ' Mb';
const rows = ni.map(iface => `
<div class="net-row">
<span style="color:var(--ink-3);font-size:12px"><i class="fa-solid fa-${iface.if_type === 'wifi' ? 'wifi' : 'ethernet'}"></i></span>
<span style="color:var(--ink-1);font-weight:600">${esc(iface.name)}</span>
<span style="color:var(--ink-3)">${spd(iface.speed_mbps)}</span>
<span style="color:var(--ink-4);font-size:9px;letter-spacing:.04em">${esc(iface.mac)}</span>
<span>WoL : ${wol(iface.wol)}</span>
<span style="color:var(--blue)">${iface.iperf_mbps != null ? iface.iperf_mbps.toFixed(1) + ' Mb/s' : '—'}</span>
</div>`).join('');
return `<div>
<div class="sec-title">RÉSEAU</div>
<div class="net-table">
<div class="net-row" style="background:var(--bg-4);font-size:9px;color:var(--ink-4);letter-spacing:.06em">
<span></span><span>INTERFACE</span><span>VITESSE</span><span>MAC</span><span>WAKE ON LAN</span><span>IPERF3</span>
</div>
${rows}
</div>
</div>`;
})()}
${(() => {
const hw = entry?.agent?.hardware_info;
if (!hw) return '';
const row = (lbl, val) => val ? `<div class="meta"><div class="meta-lbl">${lbl}</div><div class="meta-val">${esc(String(val))}</div></div>` : '';
const ramSlots = hw.ram_slots_used != null && hw.ram_slots_total != null
? `${hw.ram_slots_used}/${hw.ram_slots_total} slots` : null;
const ramInfo = [hw.ram_type, hw.ram_speed_mhz ? hw.ram_speed_mhz + ' MHz' : null, ramSlots]
.filter(Boolean).join(' · ') || null;
return `<div>
<div class="sec-title">HARDWARE</div>
<div class="meta-grid">
${row('CARTE MÈRE', hw.motherboard_vendor && hw.motherboard_model ? hw.motherboard_vendor + ' ' + hw.motherboard_model : hw.motherboard_model || hw.motherboard_vendor)}
${row('PROCESSEUR', hw.cpu_model)}
${row('MÉMOIRE RAM', ramInfo)}
</div>
</div>`;
})()}
<div>
<div class="sec-title">INFORMATIONS</div>
<div class="meta-grid">
@@ -170,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);
+4 -2
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
@@ -198,6 +198,8 @@ echo "=== Statut ==="
systemctl status nanometrics-agent --no-pager || true
echo ""
ok "Installation terminée — agent $TAG opérationnel"
echo "======================================"
echo -e " ${GREEN}${NC} Nanometrics Agent ${TAG} installé"
echo "======================================"
echo " Config : $CONFIG_FILE"
echo " Logs : journalctl -u nanometrics-agent -f"
Binary file not shown.
Binary file not shown.
+5
View File
@@ -0,0 +1,5 @@
ARG ALPINE_IMAGE=public.ecr.aws/docker/library/alpine:latest
FROM ${ALPINE_IMAGE}
RUN apk add --no-cache iperf3
EXPOSE 5201
ENTRYPOINT ["iperf3"]
+31 -6
View File
@@ -68,6 +68,8 @@ func (d *DB) migrate() error {
_, _ = d.conn.Exec(`ALTER TABLE metrics ADD COLUMN smart_hours INTEGER`)
_, _ = d.conn.Exec(`ALTER TABLE metrics ADD COLUMN smart_wear INTEGER`)
_, _ = d.conn.Exec(`ALTER TABLE metrics ADD COLUMN smart_json TEXT`)
_, _ = d.conn.Exec(`ALTER TABLE agents ADD COLUMN network_info_json TEXT`)
_, _ = d.conn.Exec(`ALTER TABLE agents ADD COLUMN hardware_info_json TEXT`)
return nil
}
@@ -75,13 +77,27 @@ func (d *DB) Close() { _ = d.conn.Close() }
func (d *DB) UpsertAgent(m *models.AgentMetrics) error {
ts := time.Now().Unix()
var netJSON, hwJSON interface{}
if len(m.NetworkInfo) > 0 {
if b, err := json.Marshal(m.NetworkInfo); err == nil {
netJSON = string(b)
}
}
if m.HardwareInfo != nil {
if b, err := json.Marshal(m.HardwareInfo); err == nil {
hwJSON = string(b)
}
}
_, err := d.conn.Exec(`
INSERT INTO agents (id, hostname, ip, status, last_seen, version)
VALUES (?, ?, ?, ?, ?, ?)
INSERT INTO agents (id, hostname, ip, status, last_seen, version,
network_info_json, hardware_info_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
ip=excluded.ip, status=excluded.status, last_seen=excluded.last_seen,
version=CASE WHEN excluded.version != '' THEN excluded.version ELSE version END`,
m.Hostname, m.Hostname, m.IP, m.Status, ts, m.Version)
version=CASE WHEN excluded.version != '' THEN excluded.version ELSE version END,
network_info_json=CASE WHEN excluded.network_info_json IS NOT NULL THEN excluded.network_info_json ELSE network_info_json END,
hardware_info_json=CASE WHEN excluded.hardware_info_json IS NOT NULL THEN excluded.hardware_info_json ELSE hardware_info_json END`,
m.Hostname, m.Hostname, m.IP, m.Status, ts, m.Version, netJSON, hwJSON)
return err
}
@@ -109,7 +125,8 @@ func (d *DB) InsertMetrics(m *models.AgentMetrics) error {
}
func (d *DB) GetAgents() ([]models.Agent, error) {
rows, err := d.conn.Query(`SELECT id, hostname, ip, status, last_seen, version FROM agents`)
rows, err := d.conn.Query(`SELECT id, hostname, ip, status, last_seen, version,
network_info_json, hardware_info_json FROM agents`)
if err != nil {
return nil, err
}
@@ -117,9 +134,17 @@ func (d *DB) GetAgents() ([]models.Agent, error) {
var agents []models.Agent
for rows.Next() {
var a models.Agent
if err := rows.Scan(&a.ID, &a.Hostname, &a.IP, &a.Status, &a.LastSeen, &a.Version); err != nil {
var netJSON, hwJSON *string
if err := rows.Scan(&a.ID, &a.Hostname, &a.IP, &a.Status, &a.LastSeen, &a.Version,
&netJSON, &hwJSON); err != nil {
return nil, err
}
if netJSON != nil {
_ = json.Unmarshal([]byte(*netJSON), &a.NetworkInfo)
}
if hwJSON != nil {
_ = json.Unmarshal([]byte(*hwJSON), &a.HardwareInfo)
}
agents = append(agents, a)
}
if err := rows.Err(); err != nil {
+12
View File
@@ -29,5 +29,17 @@ services:
depends_on:
- server
iperf3:
build:
context: .
dockerfile: Dockerfile.iperf3
args:
ALPINE_IMAGE: ${ALPINE_IMAGE:-public.ecr.aws/docker/library/alpine:latest}
restart: unless-stopped
command: ["-s"]
ports:
- "5202:5201/tcp"
- "5202:5201/udp"
volumes:
nanometrics_data:
+25
View File
@@ -24,6 +24,31 @@ func AgentsHandler(database *db.DB) http.HandlerFunc {
}
}
func AgentDetailHandler(database *db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/")
if len(parts) < 3 {
http.Error(w, "invalid path", 400)
return
}
agentID := parts[2]
agents, err := database.GetAgents()
if err != nil {
http.Error(w, err.Error(), 500)
return
}
for _, a := range agents {
if a.ID == agentID {
a.LastMetrics, _ = database.GetLastMetrics(agentID)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(a)
return
}
}
http.NotFound(w, r)
}
}
func DeleteAgentHandler(database *db.DB, broadcast func(interface{})) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/")
+2
View File
@@ -115,6 +115,8 @@ func main() {
handlers.IconGetHandler(database)(w, r)
case r.Method == http.MethodDelete:
handlers.DeleteAgentHandler(database, hub.Broadcast)(w, r)
case r.Method == http.MethodGet:
handlers.AgentDetailHandler(database)(w, r)
default:
http.NotFound(w, r)
}
+46 -23
View File
@@ -1,22 +1,43 @@
package models
type AgentMetrics struct {
Hostname string `json:"hostname"`
IP string `json:"ip"`
Status string `json:"status"`
Version string `json:"version"`
CPUPercent *float64 `json:"cpu_percent"`
MemoryUsed *int64 `json:"memory_used"`
MemoryFree *int64 `json:"memory_free"`
MemoryTotal *int64 `json:"memory_total"`
HDDUsed *int64 `json:"hdd_used"`
HDDFree *int64 `json:"hdd_free"`
HDDTotal *int64 `json:"hdd_total"`
Uptime *int64 `json:"uptime"`
NetworkRX *int64 `json:"network_rx"`
NetworkTX *int64 `json:"network_tx"`
Temperature *float64 `json:"temperature"`
Smart []SmartMetrics `json:"smart"`
Hostname string `json:"hostname"`
IP string `json:"ip"`
Status string `json:"status"`
Version string `json:"version"`
CPUPercent *float64 `json:"cpu_percent"`
MemoryUsed *int64 `json:"memory_used"`
MemoryFree *int64 `json:"memory_free"`
MemoryTotal *int64 `json:"memory_total"`
HDDUsed *int64 `json:"hdd_used"`
HDDFree *int64 `json:"hdd_free"`
HDDTotal *int64 `json:"hdd_total"`
Uptime *int64 `json:"uptime"`
NetworkRX *int64 `json:"network_rx"`
NetworkTX *int64 `json:"network_tx"`
Temperature *float64 `json:"temperature"`
Smart []SmartMetrics `json:"smart"`
NetworkInfo []NetworkInterface `json:"network_info"`
HardwareInfo *HardwareInfo `json:"hardware_info"`
}
type NetworkInterface struct {
Name string `json:"name"`
IfType string `json:"if_type"`
SpeedMbps *int64 `json:"speed_mbps"`
MAC string `json:"mac"`
WoL *bool `json:"wol"`
IperfMbps *float64 `json:"iperf_mbps"`
}
type HardwareInfo struct {
MotherboardVendor *string `json:"motherboard_vendor"`
MotherboardModel *string `json:"motherboard_model"`
CPUModel *string `json:"cpu_model"`
RAMType *string `json:"ram_type"`
RAMSpeedMHz *int64 `json:"ram_speed_mhz"`
RAMSlotsUsed *int64 `json:"ram_slots_used"`
RAMSlotsTotal *int64 `json:"ram_slots_total"`
}
type SmartMetrics struct {
@@ -29,13 +50,15 @@ type SmartMetrics struct {
}
type Agent struct {
ID string `json:"id"`
Hostname string `json:"hostname"`
IP string `json:"ip"`
Status string `json:"status"`
LastSeen int64 `json:"last_seen"`
Version string `json:"version,omitempty"`
LastMetrics *AgentMetrics `json:"last_metrics,omitempty"`
ID string `json:"id"`
Hostname string `json:"hostname"`
IP string `json:"ip"`
Status string `json:"status"`
LastSeen int64 `json:"last_seen"`
Version string `json:"version,omitempty"`
LastMetrics *AgentMetrics `json:"last_metrics,omitempty"`
NetworkInfo []NetworkInterface `json:"network_info,omitempty"`
HardwareInfo *HardwareInfo `json:"hardware_info,omitempty"`
}
type AgentConfig struct {