# Améliorations Nanometrics — Plan d'implémentation > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Ajouter métriques réseau enrichies, hardware, config bidirectionnelle, API REST complète, taille police globale. **Architecture:** - Métriques lentes (réseau, hardware) : collecte au démarrage + une fois/jour à heure fixe (config `slow_daily_time`) - Stockage dans la table `agents` (colonnes JSON), pas dans `metrics` — ces données changent rarement - API REST expose tout via les mêmes endpoints enrichis **Tech Stack:** Rust (agent), Go (server), SQLite, Vanilla JS (dashboard) --- ## Fichiers concernés | Fichier | Action | |---------|--------| | `agent/src/payload.rs` | Ajout `NetworkInterface`, `HardwareInfo`, champs dans `AgentMetrics` | | `agent/src/config.rs` | Ajout `slow_daily_time`, `network_info`, `hardware_info` dans `MetricsConfig` | | `agent/src/metrics/network_info.rs` | Nouveau module | | `agent/src/metrics/hardware.rs` | Nouveau module | | `agent/src/metrics/mod.rs` | Déclarer les 2 nouveaux modules | | `agent/src/main.rs` | Intégration scheduler, collecte slow | | `agent/Cargo.toml` | Bump version 0.1.6 | | `deploy/install.sh` | Ajout `iperf3`, `dmidecode` dans paquets | | `server/models/models.go` | Structs Go `NetworkInterface`, `HardwareInfo` | | `server/db/db.go` | Migrations + `UpsertAgent` + `GetLastMetrics` | | `server/handlers/agents.go` | Handler GET `/api/agents/{id}` | | `server/main.go` | Route `/api/agents/{id}` | | `server/docker-compose.yml` | Service iperf3 | | `dashboard/js/popups.js` | Sections réseau + hardware dans popup détail | | `dashboard/css/app.css` | Styles network/hardware section + fix font-size global | | `dashboard/js/app.js` | Fix font-size sur `html` element | --- ## Task 1 — Agent : structs payload + config **Files:** - Modify: `agent/src/payload.rs` - Modify: `agent/src/config.rs` - [ ] **Ajouter dans `payload.rs`** les nouveaux types et champs : ```rust #[derive(Serialize, Deserialize, Debug, Clone, Default)] pub struct NetworkInterface { pub name: String, pub if_type: String, // "ethernet" | "wifi" 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, } ``` Dans `AgentMetrics`, ajouter après `smart` : ```rust pub network_info: Option>, pub hardware_info: Option, ``` - [ ] **Ajouter dans `config.rs`** — `SlowMetricsConfig` + champs dans `MetricsConfig` : ```rust #[derive(Deserialize, Debug, Clone)] pub struct SlowMetricsConfig { #[serde(default)] pub udp: bool, #[serde(default)] pub mqtt: bool, } impl Default for SlowMetricsConfig { fn default() -> Self { Self { udp: true, mqtt: false } } } ``` Dans `MetricsConfig`, ajouter : ```rust #[serde(default)] pub network_info: SlowMetricsConfig, #[serde(default)] pub hardware_info: SlowMetricsConfig, #[serde(default = "default_slow_time")] pub slow_daily_time: String, // "HH:MM" ``` ```rust fn default_slow_time() -> String { "03:00".to_string() } ``` - [ ] **Vérifier** : `cargo check --manifest-path agent/Cargo.toml` - [ ] **Commit** : ```bash git add agent/src/payload.rs agent/src/config.rs git commit -m "feat(agent): structs NetworkInterface + HardwareInfo + config slow_daily_time" ``` --- ## Task 2 — Agent : module network_info **Files:** - Create: `agent/src/metrics/network_info.rs` - Modify: `agent/src/metrics/mod.rs` - [ ] **Créer `agent/src/metrics/network_info.rs`** : ```rust 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() } fn is_physical(name: &str) -> bool { // Exclure loopback, virtuels, docker, bridges if name == "lo" { return false; } for prefix in &["veth", "docker", "br-", "virbr", "vir", "tun", "tap", "dummy"] { 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(); return Some(val != "d" && !val.is_empty()); } } None } fn iperf_mbps(server_ip: &str) -> Option { // Vérifier que iperf3 est disponible if !std::process::Command::new("which").arg("iperf3") .output().map(|o| o.status.success()).unwrap_or(false) { return None; } 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); // parser "end" > "sum_received" > "bits_per_second" 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) } 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(); // Lancer iperf une seule fois pour tous (pas par interface) 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(); crate::payload::NetworkInterface { name: name.clone(), if_type: if is_wifi(name) { "wifi".to_string() } else { "ethernet".to_string() }, speed_mbps: speed, mac, wol: if is_wifi(name) { None } else { wol_status(name) }, iperf_mbps: iperf, } }).collect() } ``` - [ ] **Ajouter dans `agent/src/metrics/mod.rs`** : `pub mod network_info;` - [ ] **Vérifier** : `cargo check --manifest-path agent/Cargo.toml` - [ ] **Commit** : ```bash git add agent/src/metrics/network_info.rs agent/src/metrics/mod.rs git commit -m "feat(agent): module network_info (interfaces, WoL, iperf3)" ``` --- ## Task 3 — Agent : module hardware **Files:** - Create: `agent/src/metrics/hardware.rs` - Modify: `agent/src/metrics/mod.rs` - [ ] **Créer `agent/src/metrics/hardware.rs`** : ```rust 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<'a>(text: &'a 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" { 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; } // Type 2 = Baseboard, Type 4 = Processor, Type 17 = Memory Device let board = run_dmidecode(2); let cpu = run_dmidecode(4); let mem = run_dmidecode(17); let mut slots_total: i64 = 0; let mut slots_used: i64 = 0; let mut ram_type: Option = None; let mut ram_speed: Option = None; // Compter les slots mémoire for block in mem.split("\n\n") { if block.contains("Memory Device") { slots_total += 1; if let Some(size) = extract_field(block, "Size") { if !size.contains("No Module") { slots_used += 1; } } if ram_type.is_none() { ram_type = extract_field(block, "Type"); } if ram_speed.is_none() { if let Some(spd) = extract_field(block, "Speed") { // "3200 MT/s" → 3200 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"), 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 }, }) } ``` - [ ] **Ajouter dans `agent/src/metrics/mod.rs`** : `pub mod hardware;` - [ ] **Vérifier** : `cargo check --manifest-path agent/Cargo.toml` - [ ] **Commit** : ```bash git add agent/src/metrics/hardware.rs agent/src/metrics/mod.rs git commit -m "feat(agent): module hardware (dmidecode — carte mère, CPU, RAM)" ``` --- ## Task 4 — Agent : scheduler + intégration main.rs + install.sh + version **Files:** - Modify: `agent/src/main.rs` - Modify: `agent/Cargo.toml` - Modify: `deploy/install.sh` - [ ] **Bump version** dans `agent/Cargo.toml` : `0.1.5` → `0.1.6` - [ ] **Ajouter dans `deploy/install.sh`** les paquets `iperf3` et `dmidecode` : ```bash for pkg in curl python3 smartmontools ethtool iperf3 dmidecode; do ``` - [ ] **Ajouter dans `agent/src/main.rs`** le scheduler slow + appels modules. Après les variables `first_slow` / `last_slow`, ajouter : ```rust // 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.split(':').collect(); let h = parts.get(0).and_then(|s| s.parse().ok()).unwrap_or(3); let m = parts.get(1).and_then(|s| s.parse().ok()).unwrap_or(0); (h, m) }; let mut slow_daily_done = false; let mut slow_last_date: u32 = 0; // tm_yday pour détecter changement de jour // Collecte immédiate au démarrage if cfg.metrics.network_info.udp || cfg.metrics.network_info.mqtt { let ni = metrics::network_info::collect(&cfg.server.ip); if !ni.is_empty() { m.network_info = Some(ni); } } if cfg.metrics.hardware_info.udp || cfg.metrics.hardware_info.mqtt { m.hardware_info = metrics::hardware::collect(); } ``` Dans la boucle principale, ajouter la vérification de l'heure après le bloc `first_slow` : ```rust // Métriques lentes quotidiennes { use std::mem::MaybeUninit; let now_ts = 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_ts, tm.as_mut_ptr()) }; let tm = unsafe { tm.assume_init() }; let (cur_h, cur_m) = (tm.tm_hour as u32, tm.tm_min as u32); let cur_yday = tm.tm_yday as u32; if cur_yday != slow_last_date { slow_last_date = cur_yday; slow_daily_done = false; } if !slow_daily_done && cur_h == slow_time.0 && cur_m == 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() { m.network_info = Some(ni); } } if cfg.metrics.hardware_info.udp || cfg.metrics.hardware_info.mqtt { m.hardware_info = metrics::hardware::collect(); } } } ``` - [ ] **Vérifier** : `cargo check --manifest-path agent/Cargo.toml` - [ ] **Commit** : ```bash git add agent/src/main.rs agent/Cargo.toml deploy/install.sh git commit -m "feat(agent v0.1.6): scheduler slow metrics + réseau + hardware + iperf3/dmidecode dans install.sh" ``` --- ## Task 5 — Serveur : modèles Go + migrations DB + stockage **Files:** - Modify: `server/models/models.go` - Modify: `server/db/db.go` - [ ] **Ajouter dans `server/models/models.go`** : ```go 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"` } ``` Dans `AgentMetrics`, ajouter : ```go NetworkInfo []NetworkInterface `json:"network_info"` HardwareInfo *HardwareInfo `json:"hardware_info"` ``` Dans `Agent`, ajouter : ```go NetworkInfo []NetworkInterface `json:"network_info,omitempty"` HardwareInfo *HardwareInfo `json:"hardware_info,omitempty"` ``` - [ ] **Dans `server/db/db.go`** — migrations : Dans `migrate()`, ajouter : ```go _, _ = d.conn.Exec(`ALTER TABLE agents ADD COLUMN network_info_json TEXT`) _, _ = d.conn.Exec(`ALTER TABLE agents ADD COLUMN hardware_info_json TEXT`) ``` - [ ] **Dans `UpsertAgent()`** — stocker les données lentes si présentes : ```go 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 (?, ?, ?, ?, ?, ?) 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, network_info_json=CASE WHEN ?7 IS NOT NULL THEN ?7 ELSE network_info_json END, hardware_info_json=CASE WHEN ?8 IS NOT NULL THEN ?8 ELSE hardware_info_json END`, m.Hostname, m.Hostname, m.IP, m.Status, ts, m.Version, netJSON, hwJSON) return err } ``` - [ ] **Dans `GetAgents()`** — lire et désérialiser les colonnes JSON : ```go func (d *DB) GetAgents() ([]models.Agent, error) { rows, err := d.conn.Query(`SELECT id, hostname, ip, status, last_seen, version, network_info_json, hardware_info_json FROM agents`) // ... var netJSON, hwJSON *string if err := rows.Scan(&a.ID, &a.Hostname, &a.IP, &a.Status, &a.LastSeen, &a.Version, &netJSON, &hwJSON); err != nil { ... } if netJSON != nil { _ = json.Unmarshal([]byte(*netJSON), &a.NetworkInfo) } if hwJSON != nil { _ = json.Unmarshal([]byte(*hwJSON), &a.HardwareInfo) } } ``` - [ ] **Vérifier** : `cd server && go build ./...` - [ ] **Commit** : ```bash git add server/models/models.go server/db/db.go git commit -m "feat(server): NetworkInterface + HardwareInfo — migration DB + stockage agents" ``` --- ## Task 6 — Serveur : API GET /api/agents/{id} + docker-compose iperf3 **Files:** - Modify: `server/handlers/agents.go` - Modify: `server/main.go` - Modify: `server/docker-compose.yml` - [ ] **Ajouter dans `server/handlers/agents.go`** le handler single agent : ```go 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) } } ``` - [ ] **Dans `server/main.go`** — ajouter la route dans le switch `/api/agents/` : ```go case r.Method == http.MethodGet && !strings.HasSuffix(r.URL.Path, "/"): handlers.AgentDetailHandler(database)(w, r) ``` - [ ] **Dans `server/docker-compose.yml`** — ajouter le service iperf3 : ```yaml iperf3: image: ${IPERF3_IMAGE:-public.ecr.aws/docker/library/networkstatic/iperf3:latest} pull_policy: if_not_present restart: unless-stopped command: ["-s"] ports: - "5201:5201" ``` - [ ] **Vérifier** : `cd server && go build ./...` - [ ] **Commit** : ```bash git add server/handlers/agents.go server/main.go server/docker-compose.yml git commit -m "feat(server): GET /api/agents/{id} + service iperf3 dans compose" ``` --- ## Task 7 — Dashboard : section réseau dans popup détail **Files:** - Modify: `dashboard/js/popups.js` - Modify: `dashboard/css/app.css` - [ ] **Ajouter CSS** dans `app.css` pour la section réseau : ```css .net-table{display:flex;flex-direction:column;gap:4px} .net-row{display:grid;grid-template-columns:auto 1fr 80px 120px 60px 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} .net-row:first-child{background:var(--bg-4);font-size:9px;color:var(--ink-4);letter-spacing:.06em} .net-val{font-family:var(--font-mono);font-size:11px;color:var(--ink-2)} .hw-grid{display:grid;grid-template-columns:1fr 1fr;gap:6px} ``` - [ ] **Dans `popups.js`**, après la section STOCKAGE dans `pop-body`, ajouter les sections réseau et hardware. Construire les variables HTML : ```javascript const netSection = entry?.agent?.network_info?.length > 0 ? /* tableau des interfaces */ ... : ''; const hwSection = entry?.agent?.hardware_info ? /* grille hardware */ ... : ''; ``` Insérer `${netSection}${hwSection}` avant la section INFORMATIONS. - [ ] **Commit** : ```bash git add dashboard/js/popups.js dashboard/css/app.css git commit -m "feat(dashboard): sections réseau et hardware dans popup détail" ``` --- ## Task 8 — Dashboard : font-size global **Files:** - Modify: `dashboard/js/app.js` - Modify: `dashboard/css/app.css` - [ ] **Dans `app.js`**, changer l'application du font-size : appliquer sur `html` (root) au lieu de `body` : ```javascript if (_serverConfig.font_size) { document.documentElement.style.fontSize = _serverConfig.font_size + 'px'; } ``` - [ ] **Dans `app.css`**, vérifier que les éléments clés utilisent `rem` pour les tailles de police principales. Ajouter la règle de base sur `html` : ```css html { font-size: 13px; } /* valeur par défaut, écrasée par JS */ ``` Les éléments qui utilisent déjà des tailles en `px` absolues seront progressivement mis à l'échelle via ce mécanisme. Ceux qui héritent (`font-size: inherit`) bénéficieront automatiquement. - [ ] **Commit** : ```bash git add dashboard/js/app.js dashboard/css/app.css git commit -m "fix(dashboard): font-size global appliqué sur html root" ``` --- ## Task 9 — Release et déploiement - [ ] **Rebuild agent** : `cargo build --release --manifest-path agent/Cargo.toml` - [ ] **Copier binaires** dans `dist/` - [ ] **Rebuild Docker** : `cd server && docker compose up -d --build` - [ ] **Redéployer l'agent** via `install.sh` sur chaque VM cible - [ ] **Push final** : `git push`