21 KiB
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 dansmetrics— 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.rsles nouveaux types et champs :
#[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 :
pub network_info: Option<Vec<NetworkInterface>>,
pub hardware_info: Option<HardwareInfo>,
- Ajouter dans
config.rs—SlowMetricsConfig+ champs dansMetricsConfig:
#[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 :
#[serde(default)]
pub network_info: SlowMetricsConfig,
#[serde(default)]
pub hardware_info: SlowMetricsConfig,
#[serde(default = "default_slow_time")]
pub slow_daily_time: String, // "HH:MM"
fn default_slow_time() -> String { "03:00".to_string() }
-
Vérifier :
cargo check --manifest-path agent/Cargo.toml -
Commit :
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:
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 :
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:
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 :
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.shles paquetsiperf3etdmidecode:
for pkg in curl python3 smartmontools ethtool iperf3 dmidecode; do
- Ajouter dans
agent/src/main.rsle scheduler slow + appels modules. Après les variablesfirst_slow/last_slow, ajouter :
// 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 :
// 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 :
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:
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 :
NetworkInfo []NetworkInterface `json:"network_info"`
HardwareInfo *HardwareInfo `json:"hardware_info"`
Dans Agent, ajouter :
NetworkInfo []NetworkInterface `json:"network_info,omitempty"`
HardwareInfo *HardwareInfo `json:"hardware_info,omitempty"`
- Dans
server/db/db.go— migrations :
Dans migrate(), ajouter :
_, _ = 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 :
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 :
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 :
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.gole handler single agent :
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/:
case r.Method == http.MethodGet && !strings.HasSuffix(r.URL.Path, "/"):
handlers.AgentDetailHandler(database)(w, r)
- Dans
server/docker-compose.yml— ajouter le service iperf3 :
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 :
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.csspour la section réseau :
.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 danspop-body, ajouter les sections réseau et hardware. Construire les variables HTML :
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 :
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 surhtml(root) au lieu debody:
if (_serverConfig.font_size) {
document.documentElement.style.fontSize = _serverConfig.font_size + 'px';
}
- Dans
app.css, vérifier que les éléments clés utilisentrempour les tailles de police principales. Ajouter la règle de base surhtml:
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 :
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.shsur chaque VM cible - Push final :
git push