feat(agent-metric): implémentation Phase 3 — métriques système

- Collecte temps réel (1s) : CPU, RAM, charge réseau, top 5 processus
- Collecte medium (30min) : disques via sysinfo, températures hwmon, SMART smartctl
- Collecte statique (boot) : DMI/BIOS via /sys, interfaces réseau, CPU model
- API locale Axum sur :9101 — GET /metrics (réaltime + medium + hardware)
- Push backend : /api/v1/metrics (réaltime + medium) et /api/v1/events (hardware, boot)
- Architecture modulaire : collectors/realtime, medium, static_info
- ROADMAP Phase 3 marquée complète

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-19 06:18:08 +02:00
parent 57b5fd6b77
commit 6bda1a2b59
11 changed files with 616 additions and 20 deletions
+8 -6
View File
@@ -20,13 +20,15 @@
- [ ] `widget-network-scan` Glance (tuile + popup) — reporté Phase 4 - [ ] `widget-network-scan` Glance (tuile + popup) — reporté Phase 4
- [ ] Résolution DNS inverse (PTR) — Phase 2+ - [ ] Résolution DNS inverse (PTR) — Phase 2+
## Phase 3 — Métriques système ## Phase 3 — Métriques système
- [ ] `agent-metric` : CPU/RAM/réseau (1s) - [x] `agent-metric` : CPU/RAM/réseau/charge (1s via sysinfo)
- [ ] `agent-metric` : HDD/SMART (30min) - [x] `agent-metric` : disques, températures hwmon, SMART smartctl (30min)
- [ ] `agent-metric` : DMI/hardware (boot) - [x] `agent-metric` : DMI/hardware/BIOS depuis /sys (boot + toutes les 30min)
- [ ] Événements système - [x] Événement boot envoyé au démarrage
- [ ] `widget-agent-metrics` Glance - [x] API locale sur :9101 (GET /metrics)
- [x] Push vers /api/v1/metrics et /api/v1/events
- [ ] `widget-agent-metrics` Glance — Phase 4
## Phase 4 — UX & Personnalisation ## Phase 4 — UX & Personnalisation
+10 -6
View File
@@ -4,10 +4,14 @@ version = "0.1.0"
edition = "2021" edition = "2021"
[dependencies] [dependencies]
tokio = { workspace = true } tokio = { workspace = true }
serde = { workspace = true } serde = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }
anyhow = { workspace = true } anyhow = { workspace = true }
tracing = { workspace = true } tracing = { workspace = true }
tracing-subscriber = { workspace = true } tracing-subscriber = { workspace = true }
axum = "0.8" axum = "0.8"
serde_yaml = "0.9"
reqwest = { version = "0.12", features = ["json"] }
chrono = { version = "0.4", features = ["serde"] }
sysinfo = "0.32"
+15
View File
@@ -0,0 +1,15 @@
backend:
url: http://localhost:8080
token: ""
agent:
id: "" # auto-généré : metric-<hostname>
hostname: "" # auto-détecté depuis /etc/hostname
intervals:
realtime_ms: 1000 # CPU, RAM, réseau, charge
medium_s: 1800 # disques, températures, SMART
# DMI/hardware collecté au démarrage puis toutes les 12h
api:
listen: "0.0.0.0:9101"
+36
View File
@@ -0,0 +1,36 @@
use std::sync::Arc;
use axum::{extract::State, routing::get, Json, Router};
use tokio::sync::RwLock;
use tracing::info;
use crate::collectors::{medium::MediumMetrics, realtime::RealtimeMetrics, static_info::StaticInfo};
#[derive(Clone, Default)]
pub struct AgentState {
pub realtime: RealtimeMetrics,
pub medium: MediumMetrics,
pub hardware: Option<StaticInfo>,
}
pub type SharedState = Arc<RwLock<AgentState>>;
pub async fn serve(listen: String, state: SharedState) {
let app = Router::new()
.route("/metrics", get(get_metrics))
.route("/health", get(|| async { "ok" }))
.with_state(state);
info!("API locale agent-metric sur http://{listen}");
let listener = tokio::net::TcpListener::bind(&listen).await.expect("bind API locale");
axum::serve(listener, app).await.expect("API locale");
}
async fn get_metrics(State(state): State<SharedState>) -> Json<serde_json::Value> {
let s = state.read().await;
Json(serde_json::json!({
"realtime": s.realtime,
"medium": s.medium,
"hardware": s.hardware,
}))
}
+107
View File
@@ -0,0 +1,107 @@
use anyhow::Result;
use reqwest::Client;
use serde_json::{json, Value};
use tracing::warn;
use crate::{
collectors::{medium::MediumMetrics, realtime::RealtimeMetrics, static_info::StaticInfo},
config::Config,
};
pub struct BackendClient {
client: Client,
base_url: String,
token: String,
}
impl BackendClient {
pub fn new(cfg: &Config) -> Self {
Self {
client: Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()
.expect("client HTTP valide"),
base_url: cfg.backend.url.trim_end_matches('/').to_string(),
token: cfg.backend.token.clone(),
}
}
pub async fn register(&self, cfg: &Config) -> Result<()> {
let body = json!({
"id": cfg.agent.id,
"hostname": cfg.agent.hostname,
"agent_type": "metric",
"ip": local_ip(),
"version": env!("CARGO_PKG_VERSION"),
});
self.post("/api/v1/agents", &body).await
}
pub async fn push_realtime(&self, agent_id: &str, m: &RealtimeMetrics) -> Result<()> {
let body = json!({
"agent_id": agent_id,
"cpu_percent": m.cpu_percent,
"ram_percent": m.ram_percent,
"load_avg": m.load_avg_1,
"net_rx_bps": m.net_rx_bps,
"net_tx_bps": m.net_tx_bps,
});
self.post("/api/v1/metrics", &body).await
}
pub async fn push_medium(&self, agent_id: &str, m: &MediumMetrics) -> Result<()> {
let body = json!({
"agent_id": agent_id,
"disk_percent": m.disk_percent,
"temperature_c": m.temperatures.iter()
.filter(|t| t.label.contains("cpu") || t.label.contains("coretemp") || t.label.contains("k10temp"))
.map(|t| t.temp_c)
.next()
.unwrap_or(0.0),
"extra": {
"disks": m.disks,
"temperatures": m.temperatures,
}
});
self.post("/api/v1/metrics", &body).await
}
pub async fn push_static(&self, agent_id: &str, info: &StaticInfo) -> Result<()> {
let body = json!({
"agent_id": agent_id,
"event_type": "hardware_info",
"data": info,
});
self.post("/api/v1/events", &body).await
}
async fn post(&self, path: &str, body: &Value) -> Result<()> {
let resp = self
.client
.post(format!("{}{path}", self.base_url))
.bearer_auth(&self.token)
.json(body)
.send()
.await?;
if !resp.status().is_success() {
warn!("Backend {path} → HTTP {}", resp.status());
}
Ok(())
}
}
pub async fn push_event(client: &BackendClient, agent_id: &str, event_type: &str, data: Value) -> Result<()> {
let body = json!({
"agent_id": agent_id,
"event_type": event_type,
"data": data,
});
client.post("/api/v1/events", &body).await
}
fn local_ip() -> String {
std::net::UdpSocket::bind("0.0.0.0:0")
.and_then(|s| { s.connect("8.8.8.8:80")?; s.local_addr().map(|a| a.ip().to_string()) })
.unwrap_or_else(|_| "0.0.0.0".into())
}
@@ -0,0 +1,125 @@
use serde::{Deserialize, Serialize};
use sysinfo::Disks;
use tracing::warn;
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct MediumMetrics {
pub disks: Vec<DiskInfo>,
pub temperatures: Vec<TempSensor>,
pub disk_percent: f64, // usage global (partition la plus chargée)
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DiskInfo {
pub name: String,
pub mount: String,
pub total_gb: f64,
pub used_gb: f64,
pub percent: f64,
pub smart_ok: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TempSensor {
pub label: String,
pub temp_c: f64,
}
pub fn collect() -> MediumMetrics {
let disks = collect_disks();
let temps = collect_temperatures();
let max_pct = disks.iter().map(|d| d.percent).fold(0.0_f64, f64::max);
MediumMetrics { disks, temperatures: temps, disk_percent: max_pct }
}
fn collect_disks() -> Vec<DiskInfo> {
let mut sys_disks = Disks::new_with_refreshed_list();
sys_disks.refresh();
sys_disks
.iter()
.filter(|d| d.total_space() > 0)
.map(|d| {
let total = d.total_space();
let avail = d.available_space();
let used = total.saturating_sub(avail);
let pct = if total > 0 { used as f64 / total as f64 * 100.0 } else { 0.0 };
// SMART via smartctl (optionnel — peut échouer sans root)
let smart_ok = smart_status(d.name().to_string_lossy().as_ref());
DiskInfo {
name: d.name().to_string_lossy().into_owned(),
mount: d.mount_point().to_string_lossy().into_owned(),
total_gb: total as f64 / 1e9,
used_gb: used as f64 / 1e9,
percent: pct,
smart_ok,
}
})
.collect()
}
fn smart_status(device: &str) -> Option<bool> {
// Filtre les pseudo-systèmes de fichiers
if !device.starts_with("/dev/") {
return None;
}
let out = std::process::Command::new("smartctl")
.args(["-H", device])
.output()
.ok()?;
let stdout = String::from_utf8_lossy(&out.stdout);
if stdout.contains("PASSED") {
Some(true)
} else if stdout.contains("FAILED") {
Some(false)
} else {
None
}
}
fn collect_temperatures() -> Vec<TempSensor> {
// Lecture des capteurs hwmon Linux : /sys/class/hwmon/hwmonN/tempN_input
let mut sensors = Vec::new();
let Ok(hwmon_dir) = std::fs::read_dir("/sys/class/hwmon") else {
return sensors;
};
for entry in hwmon_dir.flatten() {
let hwmon_path = entry.path();
let name = std::fs::read_to_string(hwmon_path.join("name"))
.map(|s| s.trim().to_string())
.unwrap_or_else(|_| entry.file_name().to_string_lossy().into_owned());
// Scan des fichiers temp*_input
let Ok(files) = std::fs::read_dir(&hwmon_path) else { continue };
for f in files.flatten() {
let fname = f.file_name().to_string_lossy().into_owned();
if !fname.starts_with("temp") || !fname.ends_with("_input") {
continue;
}
let Ok(raw) = std::fs::read_to_string(f.path()) else { continue };
let Ok(millic) = raw.trim().parse::<i64>() else { continue };
let temp_c = millic as f64 / 1000.0;
// Libellé depuis temp*_label
let label_file = fname.replace("_input", "_label");
let label = std::fs::read_to_string(hwmon_path.join(label_file))
.map(|s| format!("{name}/{}", s.trim()))
.unwrap_or_else(|_| format!("{name}/{}", fname.replace("_input", "")));
if temp_c > 0.0 && temp_c < 150.0 {
sensors.push(TempSensor { label, temp_c });
}
}
}
if sensors.is_empty() {
warn!("Aucun capteur de température trouvé dans /sys/class/hwmon");
}
sensors
}
@@ -0,0 +1,3 @@
pub mod medium;
pub mod realtime;
pub mod static_info;
@@ -0,0 +1,89 @@
use serde::{Deserialize, Serialize};
use sysinfo::{Networks, System};
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct RealtimeMetrics {
pub cpu_percent: f64,
pub ram_percent: f64,
pub ram_used_mb: u64,
pub ram_total_mb: u64,
pub load_avg_1: f64,
pub net_rx_bps: i64,
pub net_tx_bps: i64,
pub top_processes: Vec<ProcessInfo>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProcessInfo {
pub name: String,
pub cpu_percent: f64,
pub ram_mb: u64,
}
pub struct RealtimeCollector {
sys: System,
networks: Networks,
// Valeurs précédentes pour calculer le débit réseau
prev_rx: u64,
prev_tx: u64,
}
impl RealtimeCollector {
pub fn new() -> Self {
let mut sys = System::new_all();
sys.refresh_all();
let networks = Networks::new_with_refreshed_list();
Self { sys, networks, prev_rx: 0, prev_tx: 0 }
}
pub fn collect(&mut self, interval_ms: u64) -> RealtimeMetrics {
self.sys.refresh_cpu_usage();
self.sys.refresh_memory();
self.sys.refresh_processes(sysinfo::ProcessesToUpdate::All, true);
self.networks.refresh();
let cpu = self.sys.global_cpu_usage() as f64;
let ram_used = self.sys.used_memory();
let ram_total = self.sys.total_memory();
let ram_pct = if ram_total > 0 { ram_used as f64 / ram_total as f64 * 100.0 } else { 0.0 };
let load = System::load_average();
// Débit réseau (delta depuis la dernière mesure)
let (rx, tx): (u64, u64) = self
.networks
.iter()
.fold((0, 0), |(r, t), (_, n)| (r + n.total_received(), t + n.total_transmitted()));
let interval_s = interval_ms.max(1) as f64 / 1000.0;
let rx_bps = ((rx.saturating_sub(self.prev_rx)) as f64 / interval_s) as i64;
let tx_bps = ((tx.saturating_sub(self.prev_tx)) as f64 / interval_s) as i64;
self.prev_rx = rx;
self.prev_tx = tx;
// Top 5 processus par CPU
let mut procs: Vec<_> = self.sys.processes().values().collect();
procs.sort_by(|a, b| b.cpu_usage().partial_cmp(&a.cpu_usage()).unwrap_or(std::cmp::Ordering::Equal));
let top_processes = procs
.iter()
.take(5)
.map(|p| ProcessInfo {
name: p.name().to_string_lossy().into_owned(),
cpu_percent: p.cpu_usage() as f64,
ram_mb: p.memory() / 1024 / 1024,
})
.collect();
RealtimeMetrics {
cpu_percent: cpu,
ram_percent: ram_pct,
ram_used_mb: ram_used / 1024 / 1024,
ram_total_mb: ram_total / 1024 / 1024,
load_avg_1: load.one,
net_rx_bps: rx_bps,
net_tx_bps: tx_bps,
top_processes,
}
}
}
@@ -0,0 +1,92 @@
use serde::{Deserialize, Serialize};
use sysinfo::{Networks, System};
use tracing::warn;
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct StaticInfo {
pub hostname: String,
pub os_name: String,
pub os_version: String,
pub kernel: String,
pub cpu_model: String,
pub cpu_cores: usize,
pub ram_total_mb: u64,
pub dmi: DmiInfo,
pub network_ifaces: Vec<IfaceInfo>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct DmiInfo {
pub board_vendor: String,
pub board_name: String,
pub product_name: String,
pub bios_vendor: String,
pub bios_version: String,
pub bios_date: String,
pub sys_vendor: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IfaceInfo {
pub name: String,
pub mac: String,
pub rx_total: u64,
pub tx_total: u64,
}
pub fn collect() -> StaticInfo {
let mut sys = System::new_all();
sys.refresh_all();
let cpu_model = sys
.cpus()
.first()
.map(|c| c.brand().to_string())
.unwrap_or_default();
let networks = Networks::new_with_refreshed_list();
let network_ifaces = networks
.iter()
.map(|(name, n)| IfaceInfo {
name: name.clone(),
mac: n.mac_address().to_string(),
rx_total: n.total_received(),
tx_total: n.total_transmitted(),
})
.collect();
StaticInfo {
hostname: System::host_name().unwrap_or_default(),
os_name: System::name().unwrap_or_default(),
os_version: System::os_version().unwrap_or_default(),
kernel: System::kernel_version().unwrap_or_default(),
cpu_model,
cpu_cores: sys.cpus().len(),
ram_total_mb: sys.total_memory() / 1024 / 1024,
dmi: read_dmi(),
network_ifaces,
}
}
fn dmi_read(file: &str) -> String {
std::fs::read_to_string(format!("/sys/devices/virtual/dmi/id/{file}"))
.map(|s| s.trim().to_string())
.unwrap_or_default()
}
fn read_dmi() -> DmiInfo {
let info = DmiInfo {
board_vendor: dmi_read("board_vendor"),
board_name: dmi_read("board_name"),
product_name: dmi_read("product_name"),
bios_vendor: dmi_read("bios_vendor"),
bios_version: dmi_read("bios_version"),
bios_date: dmi_read("bios_date"),
sys_vendor: dmi_read("sys_vendor"),
};
if info.board_name.is_empty() {
warn!("DMI non disponible (exécuter avec accès /sys/devices/virtual/dmi/id/)");
}
info
}
+52
View File
@@ -0,0 +1,52 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Config {
pub backend: BackendConfig,
pub agent: AgentConfig,
pub intervals: IntervalsConfig,
pub api: ApiConfig,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct BackendConfig {
pub url: String,
pub token: String,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct AgentConfig {
pub id: String,
pub hostname: String,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct IntervalsConfig {
pub realtime_ms: u64,
pub medium_s: u64,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ApiConfig {
pub listen: String,
}
impl Config {
pub fn load(path: &str) -> anyhow::Result<Self> {
let content = std::fs::read_to_string(path)?;
let mut cfg: Config = serde_yaml::from_str(&content)?;
cfg.resolve_defaults();
Ok(cfg)
}
fn resolve_defaults(&mut self) {
if self.agent.hostname.is_empty() {
self.agent.hostname = std::fs::read_to_string("/etc/hostname")
.map(|s| s.trim().to_string())
.unwrap_or_else(|_| "unknown".into());
}
if self.agent.id.is_empty() {
self.agent.id = format!("metric-{}", self.agent.hostname);
}
}
}
+79 -8
View File
@@ -1,18 +1,89 @@
use tracing::info; use std::sync::Arc;
use serde_json::json;
use tokio::{sync::RwLock, time};
use tracing::{error, info};
use tracing_subscriber::EnvFilter; use tracing_subscriber::EnvFilter;
mod api;
mod backend;
mod collectors;
mod config;
#[tokio::main] #[tokio::main]
async fn main() -> anyhow::Result<()> { async fn main() -> anyhow::Result<()> {
tracing_subscriber::fmt() tracing_subscriber::fmt()
.with_env_filter(EnvFilter::from_default_env()) .with_env_filter(EnvFilter::from_default_env().add_directive("info".parse()?))
.init(); .init();
info!("agent-metric démarré"); let config_path = std::env::args().nth(1).unwrap_or_else(|| "config.yaml".into());
let cfg = config::Config::load(&config_path)?;
info!("Agent ID : {} | hostname : {}", cfg.agent.id, cfg.agent.hostname);
// TODO Phase 3 : collecte CPU/RAM/GPU/réseau (1s) let client = backend::BackendClient::new(&cfg);
// TODO Phase 3 : collecte HDD/SMART/températures (30min)
// TODO Phase 3 : collecte DMI/hardware/BIOS (boot)
// TODO Phase 3 : événements système
Ok(()) // Enregistrement initial
if let Err(e) = client.register(&cfg).await {
error!("Enregistrement backend : {e}");
}
// Collecte hardware au démarrage
let hardware = collectors::static_info::collect();
info!("Hardware : {} — {} cœurs — {} Mo RAM", hardware.cpu_model, hardware.cpu_cores, hardware.ram_total_mb);
if let Err(e) = client.push_static(&cfg.agent.id, &hardware).await {
error!("Push hardware : {e}");
}
// Événement boot
if let Err(e) = backend::push_event(&client, &cfg.agent.id, "boot", json!({ "hostname": cfg.agent.hostname })).await {
error!("Push événement boot : {e}");
}
// État partagé avec l'API locale
let shared = Arc::new(RwLock::new(api::AgentState {
hardware: Some(hardware),
..Default::default()
}));
// API locale (tâche de fond)
let api_state = shared.clone();
let api_listen = cfg.api.listen.clone();
tokio::spawn(async move { api::serve(api_listen, api_state).await });
// Collecteur temps réel
let mut rt_collector = collectors::realtime::RealtimeCollector::new();
let realtime_interval = time::Duration::from_millis(cfg.intervals.realtime_ms);
let medium_interval = time::Duration::from_secs(cfg.intervals.medium_s);
// Première collecte medium au démarrage
let medium = collectors::medium::collect();
shared.write().await.medium = medium.clone();
if let Err(e) = client.push_medium(&cfg.agent.id, &medium).await {
error!("Push medium initial : {e}");
}
// Minuteries
let mut rt_ticker = time::interval(realtime_interval);
let mut med_ticker = time::interval(medium_interval);
med_ticker.tick().await; // consomme le premier tick immédiat
loop {
tokio::select! {
_ = rt_ticker.tick() => {
let metrics = rt_collector.collect(cfg.intervals.realtime_ms);
shared.write().await.realtime = metrics.clone();
if let Err(e) = client.push_realtime(&cfg.agent.id, &metrics).await {
error!("Push temps réel : {e}");
}
}
_ = med_ticker.tick() => {
info!("Collecte medium (disques, températures)…");
let metrics = collectors::medium::collect();
shared.write().await.medium = metrics.clone();
if let Err(e) = client.push_medium(&cfg.agent.id, &metrics).await {
error!("Push medium : {e}");
}
}
}
}
} }