diff --git a/ROADMAP.md b/ROADMAP.md index 87ab9ba..e61ed37 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -20,13 +20,15 @@ - [ ] `widget-network-scan` Glance (tuile + popup) — reporté Phase 4 - [ ] 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) -- [ ] `agent-metric` : HDD/SMART (30min) -- [ ] `agent-metric` : DMI/hardware (boot) -- [ ] Événements système -- [ ] `widget-agent-metrics` Glance +- [x] `agent-metric` : CPU/RAM/réseau/charge (1s via sysinfo) +- [x] `agent-metric` : disques, températures hwmon, SMART smartctl (30min) +- [x] `agent-metric` : DMI/hardware/BIOS depuis /sys (boot + toutes les 30min) +- [x] Événement boot envoyé au démarrage +- [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 diff --git a/agents/agent-metric/Cargo.toml b/agents/agent-metric/Cargo.toml index 6c12ed3..063eacf 100644 --- a/agents/agent-metric/Cargo.toml +++ b/agents/agent-metric/Cargo.toml @@ -4,10 +4,14 @@ version = "0.1.0" edition = "2021" [dependencies] -tokio = { workspace = true } -serde = { workspace = true } -serde_json = { workspace = true } -anyhow = { workspace = true } -tracing = { workspace = true } +tokio = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +anyhow = { workspace = true } +tracing = { 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" diff --git a/agents/agent-metric/config.example.yaml b/agents/agent-metric/config.example.yaml new file mode 100644 index 0000000..3105b04 --- /dev/null +++ b/agents/agent-metric/config.example.yaml @@ -0,0 +1,15 @@ +backend: + url: http://localhost:8080 + token: "" + +agent: + id: "" # auto-généré : metric- + 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" diff --git a/agents/agent-metric/src/api.rs b/agents/agent-metric/src/api.rs new file mode 100644 index 0000000..95e3747 --- /dev/null +++ b/agents/agent-metric/src/api.rs @@ -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, +} + +pub type SharedState = Arc>; + +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) -> Json { + let s = state.read().await; + Json(serde_json::json!({ + "realtime": s.realtime, + "medium": s.medium, + "hardware": s.hardware, + })) +} diff --git a/agents/agent-metric/src/backend.rs b/agents/agent-metric/src/backend.rs new file mode 100644 index 0000000..60a9a55 --- /dev/null +++ b/agents/agent-metric/src/backend.rs @@ -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()) +} diff --git a/agents/agent-metric/src/collectors/medium.rs b/agents/agent-metric/src/collectors/medium.rs new file mode 100644 index 0000000..83e91be --- /dev/null +++ b/agents/agent-metric/src/collectors/medium.rs @@ -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, + pub temperatures: Vec, + 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, +} + +#[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 { + 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 { + // 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 { + // 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::() 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 +} diff --git a/agents/agent-metric/src/collectors/mod.rs b/agents/agent-metric/src/collectors/mod.rs new file mode 100644 index 0000000..2336abb --- /dev/null +++ b/agents/agent-metric/src/collectors/mod.rs @@ -0,0 +1,3 @@ +pub mod medium; +pub mod realtime; +pub mod static_info; diff --git a/agents/agent-metric/src/collectors/realtime.rs b/agents/agent-metric/src/collectors/realtime.rs new file mode 100644 index 0000000..1204a43 --- /dev/null +++ b/agents/agent-metric/src/collectors/realtime.rs @@ -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, +} + +#[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, + } + } +} diff --git a/agents/agent-metric/src/collectors/static_info.rs b/agents/agent-metric/src/collectors/static_info.rs new file mode 100644 index 0000000..bb5f50a --- /dev/null +++ b/agents/agent-metric/src/collectors/static_info.rs @@ -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, +} + +#[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 +} diff --git a/agents/agent-metric/src/config.rs b/agents/agent-metric/src/config.rs new file mode 100644 index 0000000..708b14d --- /dev/null +++ b/agents/agent-metric/src/config.rs @@ -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 { + 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); + } + } +} diff --git a/agents/agent-metric/src/main.rs b/agents/agent-metric/src/main.rs index 7815b72..a0f8d1e 100644 --- a/agents/agent-metric/src/main.rs +++ b/agents/agent-metric/src/main.rs @@ -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; +mod api; +mod backend; +mod collectors; +mod config; + #[tokio::main] async fn main() -> anyhow::Result<()> { tracing_subscriber::fmt() - .with_env_filter(EnvFilter::from_default_env()) + .with_env_filter(EnvFilter::from_default_env().add_directive("info".parse()?)) .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) - // TODO Phase 3 : collecte HDD/SMART/températures (30min) - // TODO Phase 3 : collecte DMI/hardware/BIOS (boot) - // TODO Phase 3 : événements système + let client = backend::BackendClient::new(&cfg); - 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}"); + } + } + } + } }