# 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<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>,
}
```

Dans `AgentMetrics`, ajouter après `smart` :
```rust
pub network_info: Option<Vec<NetworkInterface>>,
pub hardware_info: Option<HardwareInfo>,
```

- [ ] **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::<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() }

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<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();
            return Some(val != "d" && !val.is_empty());
        }
    }
    None
}

fn iperf_mbps(server_ip: &str) -> Option<f64> {
    // 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<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();

    // 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::<i64>().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<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" {
                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; }

    // 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<String> = None;
    let mut ram_speed: Option<i64> = 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::<libc::tm>::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`
