feat(v0.1.5): SMART multi-disques — collecte tous les disques détectés

Agent:
- SmartMetrics + champ device (nom du disque ex: sda, nvme0)
- smart: Option<Vec<SmartMetrics>> — tous les disques, pas seulement le 1er
- collect() itère /sys/block, accumule les résultats de tous les disques valides

Serveur:
- SmartMetrics.Device + Smart []SmartMetrics dans AgentMetrics
- InsertMetrics: stocke smart_json (JSON array) au lieu de colonnes plates
- GetLastMetrics: désérialise smart_json
- Migration: smart_json TEXT ajoutée

Dashboard:
- Tuile: une icône shield/triangle par disque avec tooltip incluant le nom
- Popup détail: un bouton SMART par disque (couleur ok/err)
- showSmart(agentId, diskIdx): affiche le disque sélectionné

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Gilles Soulier
2026-05-23 05:23:23 +02:00
co-authored by Claude Sonnet 4.6
parent 1b9daae08a
commit a53923fd8e
9 changed files with 52 additions and 60 deletions
+1 -1
View File
@@ -248,7 +248,7 @@ dependencies = [
[[package]] [[package]]
name = "nanometrics-agent" name = "nanometrics-agent"
version = "0.1.4" version = "0.1.5"
dependencies = [ dependencies = [
"libc", "libc",
"rumqttc", "rumqttc",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "nanometrics-agent" name = "nanometrics-agent"
version = "0.1.4" version = "0.1.5"
edition = "2021" edition = "2021"
[lib] [lib]
+10 -7
View File
@@ -1,5 +1,4 @@
use serde::Deserialize; use serde::Deserialize;
use crate::payload::SmartMetrics;
#[derive(Deserialize)] #[derive(Deserialize)]
struct SmartJson { struct SmartJson {
@@ -42,7 +41,7 @@ pub fn is_available() -> bool {
.unwrap_or(false) .unwrap_or(false)
} }
pub fn parse_json(json: &str) -> Result<SmartMetrics, serde_json::Error> { pub fn parse_json(json: &str) -> Result<crate::payload::SmartMetrics, serde_json::Error> {
let s: SmartJson = serde_json::from_str(json)?; let s: SmartJson = serde_json::from_str(json)?;
let temperature = s.temperature.as_ref().map(|t| t.current) let temperature = s.temperature.as_ref().map(|t| t.current)
@@ -71,7 +70,8 @@ pub fn parse_json(json: &str) -> Result<SmartMetrics, serde_json::Error> {
} }
} }
Ok(SmartMetrics { Ok(crate::payload::SmartMetrics {
device: String::new(),
passed: s.smart_status.passed, passed: s.smart_status.passed,
temperature, temperature,
reallocated_sectors: reallocated, reallocated_sectors: reallocated,
@@ -80,11 +80,10 @@ pub fn parse_json(json: &str) -> Result<SmartMetrics, serde_json::Error> {
}) })
} }
pub fn collect() -> Option<SmartMetrics> { pub fn collect() -> Option<Vec<crate::payload::SmartMetrics>> {
if !is_available() { if !is_available() {
return None; return None;
} }
// Détecter les disques réels depuis /sys/block (sd*, nvme*)
let mut devs: Vec<String> = std::fs::read_dir("/sys/block") let mut devs: Vec<String> = std::fs::read_dir("/sys/block")
.into_iter() .into_iter()
.flatten() .flatten()
@@ -95,14 +94,18 @@ pub fn collect() -> Option<SmartMetrics> {
.collect(); .collect();
devs.sort(); devs.sort();
let mut results = Vec::new();
for dev in &devs { for dev in &devs {
let Ok(output) = std::process::Command::new("smartctl") let Ok(output) = std::process::Command::new("smartctl")
.args(["-j", dev]) .args(["-j", dev])
.output() else { continue }; .output() else { continue };
let json = String::from_utf8_lossy(&output.stdout); let json = String::from_utf8_lossy(&output.stdout);
if let Ok(metrics) = parse_json(&json) { if let Ok(metrics) = parse_json(&json) {
return Some(metrics); results.push(crate::payload::SmartMetrics {
device: dev.trim_start_matches("/dev/").to_string(),
..metrics
});
} }
} }
None if results.is_empty() { None } else { Some(results) }
} }
+3 -1
View File
@@ -18,11 +18,13 @@ pub struct AgentMetrics {
pub network_rx: Option<u64>, pub network_rx: Option<u64>,
pub network_tx: Option<u64>, pub network_tx: Option<u64>,
pub temperature: Option<f32>, pub temperature: Option<f32>,
pub smart: Option<SmartMetrics>, pub smart: Option<Vec<SmartMetrics>>,
} }
#[derive(Serialize, Deserialize, Debug, Clone)] #[derive(Serialize, Deserialize, Debug, Clone)]
pub struct SmartMetrics { pub struct SmartMetrics {
#[serde(default)]
pub device: String,
pub passed: bool, pub passed: bool,
pub temperature: Option<i64>, pub temperature: Option<i64>,
pub reallocated_sectors: Option<i64>, pub reallocated_sectors: Option<i64>,
+3 -2
View File
@@ -32,13 +32,14 @@ fn test_serialize_avec_smart() {
let m = AgentMetrics { let m = AgentMetrics {
hostname: "srv-01".to_string(), hostname: "srv-01".to_string(),
ip: "10.0.0.11".to_string(), ip: "10.0.0.11".to_string(),
smart: Some(SmartMetrics { smart: Some(vec![SmartMetrics {
device: "sda".to_string(),
passed: true, passed: true,
temperature: Some(34), temperature: Some(34),
reallocated_sectors: Some(0), reallocated_sectors: Some(0),
power_on_hours: Some(4213), power_on_hours: Some(4213),
wear_level: Some(98), wear_level: Some(98),
}), }]),
status: "online".to_string(), status: "online".to_string(),
..Default::default() ..Default::default()
}; };
+5 -4
View File
@@ -77,10 +77,11 @@ const Grid = (() => {
uptimeStr = d > 0 ? `${d}j ${h}h` : `${h}h`; uptimeStr = d > 0 ? `${d}j ${h}h` : `${h}h`;
} }
const smartIco = !offline && metrics?.smart != null const smartIco = !offline && metrics?.smart?.length > 0
? (metrics.smart.passed ? metrics.smart.map(s => s.passed
? `<i class="fa-solid fa-shield-check" style="color:var(--ok);font-size:10px;flex-shrink:0" data-tip="SMART OK"></i>` ? `<i class="fa-solid fa-shield-check" style="color:var(--ok);font-size:10px;flex-shrink:0" data-tip="SMART OK — ${s.device}"></i>`
: `<i class="fa-solid fa-triangle-exclamation" style="color:var(--err);font-size:10px;flex-shrink:0" data-tip="SMART FAILED"></i>`) : `<i class="fa-solid fa-triangle-exclamation" style="color:var(--err);font-size:10px;flex-shrink:0" data-tip="SMART FAILED — ${s.device}"></i>`
).join('')
: ''; : '';
const iconContent = `<img src="${API.iconUrl(id)}" alt="" const iconContent = `<img src="${API.iconUrl(id)}" alt=""
+13 -11
View File
@@ -80,15 +80,16 @@ const Popups = (() => {
? `<div class="chart-minmax"><span>min ${Grid.fmt(ramMin)}</span><span>max ${Grid.fmt(ramMax)}</span></div>` ? `<div class="chart-minmax"><span>min ${Grid.fmt(ramMin)}</span><span>max ${Grid.fmt(ramMax)}</span></div>`
: ''; : '';
const smartBtn = metrics?.smart const smartBtn = metrics?.smart?.length > 0
? `<div class="smart-btn ok" onclick="Popups.showSmart('${esc(agentId)}')" data-tip="Voir la santé complète du disque"> ? metrics.smart.map((s, i) => `
<div class="smart-dot"></div> <div class="smart-btn ${s.passed ? 'ok' : 'err'}" onclick="Popups.showSmart('${esc(agentId)}',${i})" data-tip="Voir la santé du disque ${esc(s.device)}">
<span style="font-weight:600">SMART</span> <div class="smart-dot" style="${s.passed ? '' : 'background:var(--err);box-shadow:0 0 5px var(--err)'}"></div>
<span style="font-weight:600">${esc(s.device) || 'disque'}</span>
<span>·</span> <span>·</span>
<span>${metrics.smart.passed ? 'PASSED' : 'FAILED'}</span> <span>${s.passed ? 'PASSED' : 'FAILED'}</span>
${metrics.smart.temperature ? `<span style="font-family:var(--font-mono);font-size:10px;color:var(--ink-3)"><i class="fa-solid fa-temperature-half"></i> ${metrics.smart.temperature}°C</span>` : ''} ${s.temperature != null ? `<span style="font-family:var(--font-mono);font-size:10px;color:var(--ink-3)"><i class="fa-solid fa-temperature-half"></i> ${s.temperature}°C</span>` : ''}
<i class="fa-solid fa-chevron-right" style="font-size:10px;color:var(--ink-4);margin-left:auto"></i> <i class="fa-solid fa-chevron-right" style="font-size:10px;color:var(--ink-4);margin-left:auto"></i>
</div>` </div>`).join('')
: ''; : '';
const protos = [ const protos = [
@@ -389,10 +390,11 @@ const Popups = (() => {
} }
// ══ POPUP SMART ══ // ══ POPUP SMART ══
function showSmart(agentId) { function showSmart(agentId, diskIdx = 0) {
const m = Grid.getAgent(agentId)?.metrics?.smart; const smartList = Grid.getAgent(agentId)?.metrics?.smart;
if (!m) return; if (!smartList?.length) return;
document.getElementById('smart-sub').textContent = agentId; const m = smartList[diskIdx] ?? smartList[0];
document.getElementById('smart-sub').textContent = m.device ? `${agentId} — ${m.device}` : agentId;
const passColor = m.passed ? 'var(--ok)' : 'var(--err)'; const passColor = m.passed ? 'var(--ok)' : 'var(--err)';
const passText = m.passed ? 'Disque en bonne santé' : 'Disque en mauvais état'; const passText = m.passed ? 'Disque en bonne santé' : 'Disque en mauvais état';
const passSub = m.passed const passSub = m.passed
+14 -32
View File
@@ -67,6 +67,7 @@ func (d *DB) migrate() error {
_, _ = d.conn.Exec(`ALTER TABLE metrics ADD COLUMN smart_realloc INTEGER`) _, _ = d.conn.Exec(`ALTER TABLE metrics ADD COLUMN smart_realloc INTEGER`)
_, _ = d.conn.Exec(`ALTER TABLE metrics ADD COLUMN smart_hours INTEGER`) _, _ = 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_wear INTEGER`)
_, _ = d.conn.Exec(`ALTER TABLE metrics ADD COLUMN smart_json TEXT`)
return nil return nil
} }
@@ -86,30 +87,24 @@ func (d *DB) UpsertAgent(m *models.AgentMetrics) error {
func (d *DB) InsertMetrics(m *models.AgentMetrics) error { func (d *DB) InsertMetrics(m *models.AgentMetrics) error {
ts := time.Now().Unix() ts := time.Now().Unix()
var smartPassed, smartTemp, smartRealloc, smartHours, smartWear interface{} var smartJSON interface{}
if m.Smart != nil { if len(m.Smart) > 0 {
b := 0 if b, err := json.Marshal(m.Smart); err == nil {
if m.Smart.Passed { smartJSON = string(b)
b = 1
} }
smartPassed = b
smartTemp = m.Smart.Temperature
smartRealloc = m.Smart.ReallocatedSectors
smartHours = m.Smart.PowerOnHours
smartWear = m.Smart.WearLevel
} }
_, err := d.conn.Exec(` _, err := d.conn.Exec(`
INSERT INTO metrics (agent_id, ts, INSERT INTO metrics (agent_id, ts,
cpu_percent, memory_used, memory_free, memory_total, cpu_percent, memory_used, memory_free, memory_total,
hdd_used, hdd_free, hdd_total, hdd_used, hdd_free, hdd_total,
uptime, network_rx, network_tx, temperature, uptime, network_rx, network_tx, temperature,
smart_passed, smart_temp, smart_realloc, smart_hours, smart_wear) smart_json)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
m.Hostname, ts, m.Hostname, ts,
m.CPUPercent, m.MemoryUsed, m.MemoryFree, m.MemoryTotal, m.CPUPercent, m.MemoryUsed, m.MemoryFree, m.MemoryTotal,
m.HDDUsed, m.HDDFree, m.HDDTotal, m.HDDUsed, m.HDDFree, m.HDDTotal,
m.Uptime, m.NetworkRX, m.NetworkTX, m.Temperature, m.Uptime, m.NetworkRX, m.NetworkTX, m.Temperature,
smartPassed, smartTemp, smartRealloc, smartHours, smartWear) smartJSON)
return err return err
} }
@@ -137,11 +132,8 @@ func (d *DB) GetLastMetrics(agentID string) (*models.AgentMetrics, error) {
var cpu, temperature *float64 var cpu, temperature *float64
var memUsed, memFree, memTotal, hddUsed, hddFree, hddTotal *int64 var memUsed, memFree, memTotal, hddUsed, hddFree, hddTotal *int64
var uptime, netRX, netTX *int64 var uptime, netRX, netTX *int64
var smartPassed, smartTemp, smartRealloc, smartHours, smartWear *int64 var smartJSON *string
// Chaque sous-requête prend la dernière valeur NON NULL de sa colonne.
// Nécessaire car les paquets rapides (2s) ne contiennent pas les métriques
// lentes (disque, smart) qui sont envoyées toutes les 60s.
err := d.conn.QueryRow(` err := d.conn.QueryRow(`
SELECT SELECT
(SELECT cpu_percent FROM metrics WHERE agent_id=? AND cpu_percent IS NOT NULL ORDER BY ts DESC LIMIT 1), (SELECT cpu_percent FROM metrics WHERE agent_id=? AND cpu_percent IS NOT NULL ORDER BY ts DESC LIMIT 1),
@@ -155,19 +147,15 @@ func (d *DB) GetLastMetrics(agentID string) (*models.AgentMetrics, error) {
(SELECT network_rx FROM metrics WHERE agent_id=? AND network_rx IS NOT NULL ORDER BY ts DESC LIMIT 1), (SELECT network_rx FROM metrics WHERE agent_id=? AND network_rx IS NOT NULL ORDER BY ts DESC LIMIT 1),
(SELECT network_tx FROM metrics WHERE agent_id=? AND network_tx IS NOT NULL ORDER BY ts DESC LIMIT 1), (SELECT network_tx FROM metrics WHERE agent_id=? AND network_tx IS NOT NULL ORDER BY ts DESC LIMIT 1),
(SELECT temperature FROM metrics WHERE agent_id=? AND temperature IS NOT NULL ORDER BY ts DESC LIMIT 1), (SELECT temperature FROM metrics WHERE agent_id=? AND temperature IS NOT NULL ORDER BY ts DESC LIMIT 1),
(SELECT smart_passed FROM metrics WHERE agent_id=? AND smart_passed IS NOT NULL ORDER BY ts DESC LIMIT 1), (SELECT smart_json FROM metrics WHERE agent_id=? AND smart_json IS NOT NULL ORDER BY ts DESC LIMIT 1)`,
(SELECT smart_temp FROM metrics WHERE agent_id=? AND smart_passed IS NOT NULL ORDER BY ts DESC LIMIT 1),
(SELECT smart_realloc FROM metrics WHERE agent_id=? AND smart_passed IS NOT NULL ORDER BY ts DESC LIMIT 1),
(SELECT smart_hours FROM metrics WHERE agent_id=? AND smart_passed IS NOT NULL ORDER BY ts DESC LIMIT 1),
(SELECT smart_wear FROM metrics WHERE agent_id=? AND smart_passed IS NOT NULL ORDER BY ts DESC LIMIT 1)`,
agentID, agentID, agentID, agentID, agentID, agentID, agentID, agentID,
agentID, agentID, agentID, agentID, agentID, agentID,
agentID, agentID, agentID, agentID, agentID, agentID, agentID, agentID,
agentID, agentID, agentID, agentID, agentID). agentID).
Scan(&cpu, &memUsed, &memFree, &memTotal, Scan(&cpu, &memUsed, &memFree, &memTotal,
&hddUsed, &hddFree, &hddTotal, &hddUsed, &hddFree, &hddTotal,
&uptime, &netRX, &netTX, &temperature, &uptime, &netRX, &netTX, &temperature,
&smartPassed, &smartTemp, &smartRealloc, &smartHours, &smartWear) &smartJSON)
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
return nil, nil return nil, nil
} }
@@ -187,14 +175,8 @@ func (d *DB) GetLastMetrics(agentID string) (*models.AgentMetrics, error) {
NetworkTX: netTX, NetworkTX: netTX,
Temperature: temperature, Temperature: temperature,
} }
if smartPassed != nil { if smartJSON != nil {
m.Smart = &models.SmartMetrics{ _ = json.Unmarshal([]byte(*smartJSON), &m.Smart)
Passed: *smartPassed == 1,
Temperature: smartTemp,
ReallocatedSectors: smartRealloc,
PowerOnHours: smartHours,
WearLevel: smartWear,
}
} }
return m, nil return m, nil
} }
+2 -1
View File
@@ -16,10 +16,11 @@ type AgentMetrics struct {
NetworkRX *int64 `json:"network_rx"` NetworkRX *int64 `json:"network_rx"`
NetworkTX *int64 `json:"network_tx"` NetworkTX *int64 `json:"network_tx"`
Temperature *float64 `json:"temperature"` Temperature *float64 `json:"temperature"`
Smart *SmartMetrics `json:"smart"` Smart []SmartMetrics `json:"smart"`
} }
type SmartMetrics struct { type SmartMetrics struct {
Device string `json:"device"`
Passed bool `json:"passed"` Passed bool `json:"passed"`
Temperature *int64 `json:"temperature"` Temperature *int64 `json:"temperature"`
ReallocatedSectors *int64 `json:"reallocated_sectors"` ReallocatedSectors *int64 `json:"reallocated_sectors"`