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:
+8
-6
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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,
|
||||||
|
}))
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user