22 Commits
Author SHA1 Message Date
Gilles SoulierandClaude Sonnet 4.6 f93f5741da feat: badges SMART pills, versionning serveur, fix copier HTTP
- Dashboard: icônes SMART → pills OK/USAGÉ/PREFAIL/HS cliquables
  (tuile + popup détail + popup SMART redessiné pour novices)
- Serveur: constante version 0.1.0 exposée via WS server_stats → footer
- Fix copier script install en HTTP (isSecureContext avant clipboard API)
- install.sh: ajout ethtool, suppression logique OVERWRITE_CONFIG

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 05:56:44 +02:00
Gilles SoulierandClaude Sonnet 4.6 982483e0bf chore: binaires v0.1.5 + registry ECR public pour Docker
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 05:56:36 +02:00
Gilles SoulierandClaude Sonnet 4.6 a53923fd8e 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>
2026-05-23 05:23:23 +02:00
Gilles SoulierandClaude Sonnet 4.6 1b9daae08a fix: migrations smart_* manquantes dans la table metrics
La table metrics existant avant l'ajout du SMART n'avait pas les colonnes
smart_passed/temp/realloc/hours/wear. CREATE TABLE IF NOT EXISTS ne les ajoute
pas rétroactivement — les INSERT échouaient silencieusement, data ignorée.

ALTER TABLE ... ADD COLUMN est idempotent (erreur ignorée si colonne existante).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 05:16:21 +02:00
Gilles SoulierandClaude Sonnet 4.6 fdf76477e5 feat: type de jauge configurable (compact / standard)
- ServerConfig: champ gauge_type (défaut "compact")
- CSS: classes .gs-* pour la BatteryGauge standard (label + bar 9px + gloss interne)
- Grid: helper renderGaugeRow() — sélectionne compact ou standard selon la config
- Grid: rerenderAll() pour appliquer le changement sans recharger la page
- Popup config serveur: select "Type de jauge" dans la section Affichage des tuiles

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 05:10:28 +02:00
Gilles SoulierandClaude Sonnet 4.6 22b429f247 feat: dashboard dynamique + RAM min/max dans popup
- Grid: nouvel agent ajouté en temps réel dès le 1er paquet WebSocket (plus besoin d'actualiser la page)
- Grid: ip/status mis à jour depuis chaque metrics_update (adresse DHCP fraîche)
- WS: diffuse agent_removed lors de la suppression d'un agent (sync multi-onglets)
- Popup détail: min/max RAM sur la période affichée (calculé depuis l'historique déjà chargé)
- CSS: classe .chart-minmax pour l'affichage min/max sous le graphe

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 22:53:05 +02:00
Gilles SoulierandClaude Sonnet 4.6 a2060a1713 feat(v0.1.4): SMART tile icon, IP locale robuste, copier HTTP, nettoyage UI
- Agent: détection IP via server_ip en priorité (fallback 8.8.8.8) — résout 0.0.0.0 sur LAN sans internet
- Agent: détection auto des disques /sys/block (sd*, nvme*) + fix continue dans la boucle smartctl
- Agent: SupplementaryGroups=disk dans le service systemd pour accès smartctl
- Dashboard: icône SMART (shield-check/triangle-exclamation) dans la ligne disque de la tuile
- Dashboard: bouton Copier compatible HTTP (fallback execCommand si clipboard API indisponible)
- Dashboard: suppression du texte redondant dans la section INSTALLATION AGENT

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 22:46:52 +02:00
Gilles Soulier e65770407c chore(agent): bump version 0.1.2 → 0.1.3 2026-05-22 22:35:05 +02:00
Gilles SoulierandClaude Sonnet 4.6 9e77d961f5 feat(agent): déconnexion propre sur SIGTERM/SIGINT
- Capture SIGTERM et SIGINT via libc::signal → AtomicBool RUNNING
- La boucle principale s'arrête proprement à la prochaine itération
- Envoi d'un paquet status:offline via UDP avant de quitter
- MQTT : publish status offline + disconnect() pour déconnexion gracieuse
  (le last_will reste actif pour les déconnexions brutales)
- payload.rs: #[serde(default)] sur version pour compatibilité descendante

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 22:34:55 +02:00
Gilles SoulierandClaude Sonnet 4.6 3933301cff fix(db): GetLastMetrics retourne la dernière valeur non-nulle par colonne
La requête précédente prenait la dernière ligne (paquet rapide, 2s) qui
a hdd_*/smart_* à NULL. Chaque sous-requête cible maintenant la dernière
valeur non-nulle indépendamment, ce qui restitue les données disque/smart
au rechargement même si le dernier paquet ne les contenait pas.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 22:29:06 +02:00
Gilles SoulierandClaude Sonnet 4.6 9f87c9294d revert(docker): retour au multi-stage, docker login requis pour le pull
Dockerfile multi-stage (golang:1.22-alpine → scratch) pour un build
autonome. docker-compose sans version obsolète, pull:false pour le
builder, pull_policy:if_not_present pour nginx.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 22:12:56 +02:00
Gilles SoulierandClaude Sonnet 4.6 638d347bb0 fix(docker): évite les pulls Docker Hub inutiles (rate limit 429)
- Retire l'attribut version obsolète
- build.pull: false — BuildKit ne vérifie plus le manifest pour golang:1.22-alpine
- pull_policy: if_not_present — nginx:alpine n'est tiré que si absent du cache

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 22:06:41 +02:00
Gilles Soulier 8f3dbd0532 3 2026-05-22 22:06:12 +02:00
Gilles SoulierandClaude Sonnet 4.6 99bdf79a63 fix(docker): remplace alpine:3.19 par scratch pour éviter le rate limit
Le binaire est statique (CGO_ENABLED=0) — scratch suffit. Seuls les
certificats TLS sont copiés depuis le builder golang:1.22-alpine.
Élimine le pull de docker.io/library/alpine qui déclenche le 429.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 22:03:51 +02:00
Gilles SoulierandClaude Sonnet 4.6 a22d1f4cd2 fix(tile): icône personnalisée masque le fallback FA au chargement
Le span de fallback (fa-server) démarrait en display:flex — visible en
permanence derrière l'image. Il passe à display:none et n'est affiché
que si l'img déclenche onerror (pas d'icône).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 22:02:11 +02:00
Gilles SoulierandClaude Sonnet 4.6 d8f395cb53 feat(dashboard): métriques chargées immédiatement au rechargement de page
GET /api/agents inclut désormais last_metrics (dernière ligne de la table
metrics) pour chaque agent. grid.js l'utilise lors du refresh initial, ce
qui peuple les tuiles sans attendre le prochain message WebSocket.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 22:00:33 +02:00
Gilles SoulierandClaude Sonnet 4.6 f69c22039b fix(icon): upload d'icône — retour d'erreur, WEBP, limite Nginx
- nginx: client_max_body_size 10m (limite par défaut 1 Mo bloquait les images)
- icons.go: import _ golang.org/x/image/webp et image/gif pour décoder WEBP/GIF
- index.html: retire SVG de l'accept (serveur le rejette) et corrige le hint
- popups.js: try/catch autour de uploadIcon → message d'erreur visible dans le hint
  pendant 4s si l'upload échoue ; reset du file input pour re-sélectionner le même
  fichier ; rafraîchit l'img de la tuile avec cache-busting après succès

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 21:58:46 +02:00
Gilles SoulierandClaude Sonnet 4.6 2bda420728 feat(dashboard): affichage disque en Go utilisé/total comme la RAM
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 21:51:17 +02:00
Gilles SoulierandClaude Sonnet 4.6 f604e22f6e fix(deploy): permissions config et prompt d'écrasement au réinstall
- ConfigurationDirectoryMode 0750→0755 : le DynamicUser (sans groupe root)
  peut maintenant traverser /etc/nanometrics et lire config.toml
- chmod 644 systématique sur config.toml même si conservé (corrige les
  anciennes installs en 640 qui causent un PermissionDenied au démarrage)
- Prompt interactif si config existe : o=écraser, N=conserver ; variable
  OVERWRITE_CONFIG=true pour forcer sans interaction (curl|bash)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 21:49:13 +02:00
Gilles SoulierandClaude Sonnet 4.6 8d4dc0e853 fix(deploy): arrêt du service avant remplacement du binaire
Évite l'erreur "Fichier texte occupé" lors d'une mise à jour à chaud.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 20:32:17 +02:00
Gilles Soulier 311bdbc66d chore: version agent 0.1.2 2026-05-22 20:28:30 +02:00
Gilles SoulierandClaude Sonnet 4.6 0df716b8b0 feat: version agent remontée au serveur et affichée dans la popup
- payload.rs : champ version (env!("CARGO_PKG_VERSION"))
- models.go  : Version dans AgentMetrics et Agent
- db.go      : colonne version dans agents + migration ALTER TABLE
- popups.js  : badge version dans la section INFORMATIONS

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 20:27:26 +02:00
30 changed files with 519 additions and 160 deletions
+1 -1
View File
@@ -248,7 +248,7 @@ dependencies = [
[[package]]
name = "nanometrics-agent"
version = "0.1.0"
version = "0.1.5"
dependencies = [
"libc",
"rumqttc",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "nanometrics-agent"
version = "0.1.0"
version = "0.1.5"
edition = "2021"
[lib]
+46 -7
View File
@@ -2,13 +2,26 @@ use nanometrics_agent::{config, metrics, payload, transport};
use sysinfo::{Components, Networks, System};
use std::time::{Duration, Instant};
use std::sync::mpsc;
use std::sync::atomic::{AtomicBool, Ordering};
fn get_local_ip() -> String {
static RUNNING: AtomicBool = AtomicBool::new(true);
extern "C" fn handle_signal(_: libc::c_int) {
RUNNING.store(false, Ordering::Relaxed);
}
fn get_local_ip(server_ip: &str) -> String {
use std::net::UdpSocket;
if let Ok(s) = UdpSocket::bind("0.0.0.0:0") {
if s.connect("8.8.8.8:80").is_ok() {
if let Ok(addr) = s.local_addr() {
return addr.ip().to_string();
// Try server IP first (always reachable), then internet fallback
for target in &[format!("{}:80", server_ip), "8.8.8.8:80".to_string()] {
if let Ok(s) = UdpSocket::bind("0.0.0.0:0") {
if s.connect(target.as_str()).is_ok() {
if let Ok(addr) = s.local_addr() {
let ip = addr.ip().to_string();
if ip != "0.0.0.0" {
return ip;
}
}
}
}
}
@@ -30,7 +43,7 @@ fn main() {
.expect("Impossible de charger config.toml");
let hostname = System::host_name().unwrap_or_else(|| "unknown".to_string());
let ip = get_local_ip();
let ip = get_local_ip(&cfg.server.ip);
let mut sys = System::new();
let mut networks = Networks::new_with_refreshed_list();
@@ -49,12 +62,17 @@ fn main() {
None
};
unsafe {
libc::signal(libc::SIGTERM, handle_signal as libc::sighandler_t);
libc::signal(libc::SIGINT, handle_signal as libc::sighandler_t);
}
let mut last_slow = Instant::now();
let mut last_medium = Instant::now();
let mut first_medium = true;
let mut first_slow = true;
loop {
while RUNNING.load(Ordering::Relaxed) {
let now = Instant::now();
while let Ok(transport::mqtt::MqttIncoming::ConfigUpdate(data)) = incoming_rx.try_recv() {
@@ -68,6 +86,7 @@ fn main() {
hostname: hostname.clone(),
ip: ip.clone(),
status: "online".to_string(),
version: env!("CARGO_PKG_VERSION").to_string(),
..Default::default()
};
@@ -133,4 +152,24 @@ fn main() {
std::thread::sleep(Duration::from_secs(2));
}
// Déconnexion propre : notifier le serveur avant de quitter
let offline = serde_json::to_string(&payload::AgentMetrics {
hostname: hostname.clone(),
ip: ip.clone(),
status: "offline".to_string(),
version: env!("CARGO_PKG_VERSION").to_string(),
..Default::default()
}).unwrap_or_default();
if let Some(ref udp) = udp_sender {
udp.send(&offline);
}
if let Some(ref client) = mqtt_client {
transport::mqtt::publish_status(
client, &cfg.protocols.mqtt.topic_base, &hostname, "offline",
);
std::thread::sleep(Duration::from_millis(200)); // laisser le temps au broker de recevoir
let _ = client.disconnect();
}
}
+23 -10
View File
@@ -1,5 +1,4 @@
use serde::Deserialize;
use crate::payload::SmartMetrics;
#[derive(Deserialize)]
struct SmartJson {
@@ -42,7 +41,7 @@ pub fn is_available() -> bool {
.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 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,
temperature,
reallocated_sectors: reallocated,
@@ -80,19 +80,32 @@ 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() {
return None;
}
for dev in &["/dev/sda", "/dev/nvme0"] {
let output = std::process::Command::new("smartctl")
let mut devs: Vec<String> = std::fs::read_dir("/sys/block")
.into_iter()
.flatten()
.flatten()
.map(|e| e.file_name().into_string().unwrap_or_default())
.filter(|n| n.starts_with("sd") || n.starts_with("nvme"))
.map(|n| format!("/dev/{}", n))
.collect();
devs.sort();
let mut results = Vec::new();
for dev in &devs {
let Ok(output) = std::process::Command::new("smartctl")
.args(["-j", dev])
.output()
.ok()?;
.output() else { continue };
let json = String::from_utf8_lossy(&output.stdout);
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) }
}
+5 -1
View File
@@ -5,6 +5,8 @@ pub struct AgentMetrics {
pub hostname: String,
pub ip: String,
pub status: String,
#[serde(default)]
pub version: String,
pub cpu_percent: Option<f32>,
pub memory_used: Option<u64>,
pub memory_free: Option<u64>,
@@ -16,11 +18,13 @@ pub struct AgentMetrics {
pub network_rx: Option<u64>,
pub network_tx: Option<u64>,
pub temperature: Option<f32>,
pub smart: Option<SmartMetrics>,
pub smart: Option<Vec<SmartMetrics>>,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct SmartMetrics {
#[serde(default)]
pub device: String,
pub passed: bool,
pub temperature: Option<i64>,
pub reallocated_sectors: Option<i64>,
+5
View File
@@ -78,3 +78,8 @@ pub fn publish_metrics(client: &Client, topic_base: &str, hostname: &str, json:
let topic = format!("{}/{}/metrics", topic_base, hostname);
let _ = client.publish(topic, QoS::AtMostOnce, false, json);
}
pub fn publish_status(client: &Client, topic_base: &str, hostname: &str, status: &str) {
let topic = format!("{}/{}/status", topic_base, hostname);
let _ = client.publish(topic, QoS::AtLeastOnce, true, status);
}
+4 -2
View File
@@ -18,6 +18,7 @@ fn test_serialize_json_complet() {
temperature: None,
smart: None,
status: "online".to_string(),
version: "0.0.0".to_string(),
};
let json = serde_json::to_string(&m).unwrap();
assert!(json.contains("\"hostname\":\"srv-01\""));
@@ -31,13 +32,14 @@ fn test_serialize_avec_smart() {
let m = AgentMetrics {
hostname: "srv-01".to_string(),
ip: "10.0.0.11".to_string(),
smart: Some(SmartMetrics {
smart: Some(vec![SmartMetrics {
device: "sda".to_string(),
passed: true,
temperature: Some(34),
reallocated_sectors: Some(0),
power_on_hours: Some(4213),
wear_level: Some(98),
}),
}]),
status: "online".to_string(),
..Default::default()
};
Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+21 -6
View File
@@ -117,6 +117,17 @@ body{background:var(--bg-1);color:var(--ink-1);font-family:var(--font-ui);font-s
.g-fill.w{background:var(--warn)}.g-fill.e{background:var(--err)}
.g-val{font-family:var(--font-mono);font-size:11px;color:var(--ink-2);
min-width:34px;white-space:nowrap;flex-shrink:0;text-align:right}
/* Jauge standard (BatteryGauge) */
.gs-row{display:flex;flex-direction:column;gap:3px}
.gs-header{display:flex;align-items:center;gap:6px}
.gs-ico{width:14px;text-align:center;font-size:10px;color:var(--ink-3);flex-shrink:0;cursor:help}
.gs-lbl{flex:1;font-family:var(--font-terminal);font-size:10px;color:var(--ink-3);letter-spacing:.04em}
.gs-val{font-family:var(--font-mono);font-size:11px;color:var(--ink-2);white-space:nowrap;flex-shrink:0}
.gs-bar{position:relative;height:9px;border-radius:3px;background:var(--bg-1);
border:1px solid var(--border-1);overflow:hidden;box-shadow:inset 0 1px 2px rgba(0,0,0,.3)}
.gs-fill{height:100%;border-radius:2px;background:var(--ok);transition:width .3s}
.gs-fill.w{background:var(--warn)}.gs-fill.e{background:var(--err)}
.gs-gloss{position:absolute;inset:0;background:linear-gradient(180deg,rgba(255,255,255,.12),transparent);pointer-events:none}
.tile-foot{font-family:var(--font-terminal);font-size:10px;color:var(--ink-4);
display:flex;align-items:center;justify-content:space-between;user-select:none}
.tile-foot-info{display:flex;align-items:center;gap:5px;min-width:0;overflow:hidden}
@@ -187,12 +198,16 @@ body{background:var(--bg-1);color:var(--ink-1);font-family:var(--font-ui);font-s
.chart-cur{font-family:var(--font-mono);font-size:16px;font-weight:700}
.chart-svg{width:100%;height:52px;display:block}
.chart-axis{display:flex;justify-content:space-between;margin-top:2px;font-family:var(--font-terminal);font-size:9px;color:var(--ink-4)}
.smart-btn{display:inline-flex;align-items:center;gap:8px;padding:7px 12px;border-radius:8px;
border:1px solid var(--border-2);background:var(--bg-3);cursor:pointer;
transition:background .12s,border-color .12s,transform .08s;font-family:var(--font-terminal);font-size:11px}
.smart-btn:hover{background:var(--bg-4)}.smart-btn:active{transform:translateY(1px)}
.smart-btn.ok{border-color:rgba(77,187,38,.3);color:var(--ok)}
.smart-dot{width:7px;height:7px;border-radius:50%;background:var(--ok);box-shadow:0 0 5px var(--ok)}
.chart-minmax{display:flex;justify-content:space-between;margin-top:3px;font-family:var(--font-mono);font-size:9px;color:var(--ink-4)}
.smart-pill{display:inline-flex;align-items:center;gap:3px;padding:1px 7px;border-radius:999px;
font-size:9px;font-family:var(--font-terminal);font-weight:700;border:1px solid;
cursor:pointer;user-select:none;flex-shrink:0;
transition:opacity .12s,transform .08s,box-shadow .12s}
.smart-pill:hover{opacity:.82;transform:scale(1.06)}
.smart-pill.ok{color:var(--ok);background:rgba(77,187,38,.12);border-color:rgba(77,187,38,.32)}
.smart-pill.old{color:var(--warn);background:rgba(250,189,47,.12);border-color:rgba(250,189,47,.32)}
.smart-pill.prefail{color:var(--accent);background:var(--accent-tint);border-color:rgba(254,128,25,.32)}
.smart-pill.hs{color:var(--err);background:rgba(251,73,52,.12);border-color:rgba(251,73,52,.32)}
.meta-grid{display:grid;grid-template-columns:1fr 1fr;gap:6px}
.meta{background:var(--bg-3);border-radius:6px;padding:8px 10px;border:1px solid var(--border-1)}
.meta-lbl{font-size:9px;color:var(--ink-4);font-family:var(--font-terminal);letter-spacing:.06em}
+7 -3
View File
@@ -57,6 +57,10 @@
<span class="f-val" id="srv-mem"></span>
<div class="f-minibar"><div class="f-minifill" id="srv-mem-bar"></div></div>
</div>
<div class="f-cell" style="gap:4px">
<i class="fa-solid fa-code-branch" style="font-size:9px;color:var(--ink-4)"></i>
<span id="srv-ver" style="font-family:var(--font-mono);font-size:9px;color:var(--ink-4)"></span>
</div>
<div class="f-spacer"></div>
<div class="f-right">
<i class="fa-solid fa-rotate"></i>
@@ -73,12 +77,12 @@
<img id="pop-icon-img" src="" alt="" style="display:none">
<div class="agent-icon-overlay"><i class="fa-solid fa-camera"></i><span>Changer</span></div>
</div>
<input type="file" id="icon-upload" accept=".svg,.jpg,.jpeg,.png,.webp" style="display:none">
<input type="file" id="icon-upload" accept=".jpg,.jpeg,.png,.webp" style="display:none">
<div style="flex:1">
<div class="pop-host" id="pop-host"></div>
<div class="pop-ip" id="pop-ip"></div>
<div style="font-size:10px;color:var(--ink-4);font-family:var(--font-terminal);margin-top:2px">
Cliquer sur l'icône pour personnaliser · SVG JPG PNG WEBP · max 128×128 px
<div id="icon-hint" style="font-size:10px;color:var(--ink-4);font-family:var(--font-terminal);margin-top:2px">
Cliquer sur l'icône pour personnaliser · JPG PNG WEBP · max 128×128 px
</div>
</div>
<div class="pop-led" id="pop-led"></div>
+4
View File
@@ -50,6 +50,8 @@ const App = (() => {
const memEl = document.getElementById('srv-mem');
const cpuBar = document.getElementById('srv-cpu-bar');
const memBar = document.getElementById('srv-mem-bar');
const verEl = document.getElementById('srv-ver');
if (verEl && stats.version) verEl.textContent = 'v' + stats.version;
if (cpuEl) {
cpuEl.textContent = cpu.toFixed(0) + '%';
cpuEl.className = 'f-val' + (cpu >= 70 ? ' w' : '');
@@ -87,6 +89,8 @@ const App = (() => {
updateServerStats(msg.data);
} else if (msg.type === 'status_update') {
Grid.updateStatus(msg.agent_id, msg.data.status);
} else if (msg.type === 'agent_removed') {
Grid.removeAgent(msg.agent_id);
}
} catch {}
};
+85 -23
View File
@@ -36,6 +36,40 @@ const Grid = (() => {
return '';
}
function renderGaugeRow(faIcon, tip, label, pct, fillClass, valStr, extra) {
const standard = (App.serverConfig?.gauge_type ?? 'compact') === 'standard';
if (standard) {
return `<div class="gs-row">
<div class="gs-header">
<span class="gs-ico" data-tip="${tip}"><i class="fa-solid fa-${faIcon}"></i></span>
<span class="gs-lbl">${label}</span>
<span class="gs-val">${valStr}</span>${extra || ''}
</div>
<div class="gs-bar">
<div class="gs-fill ${fillClass}" style="width:${(pct ?? 0).toFixed(1)}%"></div>
<div class="gs-gloss"></div>
</div>
</div>`;
}
return `<div class="g-row">
<div class="g-ico" data-tip="${tip}"><i class="fa-solid fa-${faIcon}"></i></div>
<div class="g-bar"><div class="g-fill ${fillClass}" style="width:${(pct ?? 0).toFixed(0)}%"></div></div>
<span class="g-val">${valStr}</span>${extra || ''}
</div>`;
}
const _stateLabel = { ok: 'OK', old: 'USAGÉ', prefail: 'PREFAIL', hs: 'HS' };
function smartState(s) {
if (!s.passed) return 'hs';
if (s.reallocated_sectors > 0 ||
(s.wear_level != null && s.wear_level < 20) ||
(s.power_on_hours != null && s.power_on_hours > 40000)) return 'prefail';
if ((s.wear_level != null && s.wear_level < 50) ||
(s.power_on_hours != null && s.power_on_hours > 25000)) return 'old';
return 'ok';
}
function renderTile(agent, metrics) {
const id = agent.id;
const sc = statusClass(agent);
@@ -55,10 +89,21 @@ const Grid = (() => {
uptimeStr = d > 0 ? `${d}j ${h}h` : `${h}h`;
}
const smartIco = !offline && metrics?.smart?.length > 0
? '<div style="display:flex;gap:3px;flex-shrink:0">' +
metrics.smart.map((s, i) => {
const st = smartState(s);
const lbl = _stateLabel[st];
return `<span class="smart-pill ${st}"
onclick="event.stopPropagation();Popups.showSmart('${esc(id)}',${i})"
data-tip="SMART ${esc(s.device)}${lbl}">${lbl}</span>`;
}).join('') + '</div>'
: '';
const iconContent = `<img src="${API.iconUrl(id)}" alt=""
style="width:100%;height:100%;object-fit:cover;border-radius:7px"
onerror="this.style.display='none';this.nextSibling.style.display='flex'">
<span style="display:flex;align-items:center;justify-content:center;width:100%;height:100%;color:var(--accent)">
<span style="display:none;align-items:center;justify-content:center;width:100%;height:100%;color:var(--accent)">
<i class="fa-solid fa-server"></i></span>`;
return `<div class="tile ${sc}" id="tile-${id}" onclick="Popups.showDetail('${esc(id)}')">
@@ -71,24 +116,19 @@ const Grid = (() => {
<div class="t-led ${ledClass(agent.status)}"></div>
</div>
<div class="tile-gauges">
<div class="g-row">
<div class="g-ico" data-tip="CPU"><i class="fa-solid fa-microchip"></i></div>
<div class="g-bar"><div class="g-fill ${offline ? '' : gFill(cpu ?? 0)}"
style="width:${offline ? 0 : (cpu ?? 0).toFixed(0)}%"></div></div>
<span class="g-val">${offline ? '—' : fmtPct(cpu)}</span>
</div>
<div class="g-row">
<div class="g-ico" data-tip="RAM"><i class="fa-solid fa-memory"></i></div>
<div class="g-bar"><div class="g-fill ${offline ? '' : gFill(memPct ?? 0)}"
style="width:${offline ? 0 : (memPct ?? 0).toFixed(0)}%"></div></div>
<span class="g-val">${offline ? '' : (metrics?.memory_used && metrics?.memory_total ? fmt(metrics.memory_used) + '/' + fmt(metrics.memory_total) : '')}</span>
</div>
<div class="g-row">
<div class="g-ico" data-tip="Disque"><i class="fa-solid fa-hard-drive"></i></div>
<div class="g-bar"><div class="g-fill ${offline ? '' : (diskPct >= (App.serverConfig?.warn_disk ?? 75) ? 'w' : '')}"
style="width:${offline ? 0 : (diskPct ?? 0).toFixed(0)}%"></div></div>
<span class="g-val">${offline ? '—' : fmtPct(diskPct)}</span>
</div>
${renderGaugeRow('microchip', 'CPU', 'CPU',
offline ? 0 : (cpu ?? 0),
offline ? '' : gFill(cpu ?? 0),
offline ? '—' : fmtPct(cpu))}
${renderGaugeRow('memory', 'RAM', 'MÉMOIRE',
offline ? 0 : (memPct ?? 0),
offline ? '' : gFill(memPct ?? 0),
offline ? '—' : (metrics?.memory_used && metrics?.memory_total ? fmt(metrics.memory_used) + '/' + fmt(metrics.memory_total) : '—'))}
${renderGaugeRow('hard-drive', 'Disque', 'DISQUE',
offline ? 0 : (diskPct ?? 0),
offline ? '' : (diskPct >= (App.serverConfig?.warn_disk ?? 75) ? 'w' : ''),
offline ? '—' : (metrics?.hdd_used && metrics?.hdd_total ? fmt(metrics.hdd_used) + '/' + fmt(metrics.hdd_total) : '—'),
smartIco)}
</div>
<div class="tile-foot">
<span class="tile-foot-info">
@@ -105,8 +145,24 @@ const Grid = (() => {
}
function update(agentId, metrics) {
const entry = _agents.get(agentId);
if (!entry) return;
let entry = _agents.get(agentId);
if (!entry) {
// Nouvel agent découvert via WebSocket — on crée la tuile à la volée
const agent = {
id: agentId,
hostname: metrics.hostname || agentId,
ip: metrics.ip || '',
status: metrics.status || 'online',
};
_agents.set(agentId, { agent, metrics });
const grid = document.getElementById('agents-grid');
if (grid) grid.insertAdjacentHTML('beforeend', renderTile(agent, metrics));
updateStats();
return;
}
// Mettre à jour ip/status depuis les métriques fraîches
if (metrics.ip) entry.agent.ip = metrics.ip;
if (metrics.status) entry.agent.status = metrics.status;
// Conserver les valeurs lentes (disque, smart) quand le paquet ne les contient pas
if (entry.metrics) {
for (const k of Object.keys(entry.metrics)) {
@@ -124,7 +180,7 @@ const Grid = (() => {
function refresh(agents) {
agents.forEach(a => {
if (!_agents.has(a.id)) {
_agents.set(a.id, { agent: a, metrics: null });
_agents.set(a.id, { agent: a, metrics: a.last_metrics || null });
} else {
_agents.get(a.id).agent = a;
}
@@ -151,6 +207,12 @@ const Grid = (() => {
document.getElementById('stat-err').textContent = err;
}
function rerenderAll() {
const grid = document.getElementById('agents-grid');
if (!grid) return;
grid.innerHTML = [..._agents.values()].map(({ agent, metrics }) => renderTile(agent, metrics)).join('');
}
function removeAgent(id) {
_agents.delete(id);
const el = document.getElementById('tile-' + id);
@@ -169,5 +231,5 @@ const Grid = (() => {
updateStats();
}
return { refresh, update, updateStatus, removeAgent, getAgent, fmt, fmtPct };
return { refresh, update, updateStatus, removeAgent, rerenderAll, getAgent, fmt, fmtPct, smartState };
})();
+146 -40
View File
@@ -30,8 +30,27 @@ const Popups = (() => {
document.getElementById('icon-upload').onchange = async (e) => {
const file = e.target.files[0];
if (!file) return;
await API.uploadIcon(agentId, file);
img.src = API.iconUrl(agentId) + '?t=' + Date.now();
const hint = document.getElementById('icon-hint');
try {
await API.uploadIcon(agentId, file);
const ts = '?t=' + Date.now();
img.src = API.iconUrl(agentId) + ts;
img.style.display = 'block';
document.getElementById('pop-icon-fa').style.display = 'none';
const tileImg = document.querySelector(`#tile-${CSS.escape(agentId)} .t-icon img`);
if (tileImg) tileImg.src = API.iconUrl(agentId) + ts;
} catch (err) {
if (hint) {
hint.style.color = 'var(--err)';
hint.textContent = 'Erreur : ' + (err.message || 'téléversement échoué');
setTimeout(() => {
hint.style.color = '';
hint.textContent = 'Cliquer sur l\'icône pour personnaliser · JPG PNG WEBP · max 128×128 px';
}, 4000);
}
} finally {
e.target.value = '';
}
};
// Uptime
@@ -50,15 +69,29 @@ const Popups = (() => {
const cpuPts = Charts.historyToCpuPts(history);
const memPts = Charts.historyToMemPts(history);
const smartBtn = metrics?.smart
? `<div class="smart-btn ok" onclick="Popups.showSmart('${esc(agentId)}')" data-tip="Voir la santé complète du disque">
<div class="smart-dot"></div>
<span style="font-weight:600">SMART</span>
<span>·</span>
<span>${metrics.smart.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>` : ''}
<i class="fa-solid fa-chevron-right" style="font-size:10px;color:var(--ink-4);margin-left:auto"></i>
</div>`
let ramMin = null, ramMax = null;
for (const h of history) {
if (h.memory_used != null) {
if (ramMin === null || h.memory_used < ramMin) ramMin = h.memory_used;
if (ramMax === null || h.memory_used > ramMax) ramMax = h.memory_used;
}
}
const ramMinMax = ramMin !== null
? `<div class="chart-minmax"><span>min ${Grid.fmt(ramMin)}</span><span>max ${Grid.fmt(ramMax)}</span></div>`
: '';
const smartBadges = metrics?.smart?.length > 0
? '<div style="display:flex;gap:6px;flex-wrap:wrap;margin-top:6px">' +
metrics.smart.map((s, i) => {
const st = Grid.smartState(s);
const lbl = { ok: 'OK', old: 'USAGÉ', prefail: 'PREFAIL', hs: 'HS' }[st];
return `<span class="smart-pill ${st}"
onclick="Popups.showSmart('${esc(agentId)}',${i})"
data-tip="Santé SMART de ${esc(s.device)}">
<i class="fa-solid fa-hard-drive" style="font-size:8px"></i>
${esc(s.device)} · ${lbl}
</span>`;
}).join('') + '</div>'
: '';
const protos = [
@@ -99,6 +132,7 @@ const Popups = (() => {
</div>
<svg class="chart-svg" viewBox="0 0 200 52" preserveAspectRatio="none" id="det-mem-chart"></svg>
<div class="chart-axis"><span>30min</span><span>15min</span><span>now</span></div>
${ramMinMax}
</div>
</div>
</div>
@@ -111,7 +145,7 @@ const Popups = (() => {
<div style="height:100%;border-radius:4px;background:var(--ok);width:${metrics?.hdd_total ? (metrics.hdd_used/metrics.hdd_total*100).toFixed(0) : 0}%"></div></div>
<span style="font-family:var(--font-mono);font-size:12px;color:var(--ink-2);width:90px;text-align:right">${Grid.fmt(metrics?.hdd_used)} / ${Grid.fmt(metrics?.hdd_total)}</span>
</div>
${smartBtn}
${smartBadges}
</div>
</div>
<div>
@@ -119,6 +153,9 @@ const Popups = (() => {
<div class="meta-grid">
<div class="meta"><div class="meta-lbl">HOSTNAME</div><div class="meta-val">${esc(agent.hostname)}</div></div>
<div class="meta"><div class="meta-lbl">ADRESSE IP</div><div class="meta-val">${esc(agent.ip) || '—'}</div></div>
<div class="meta"><div class="meta-lbl">VERSION AGENT</div><div class="meta-val" style="display:flex;align-items:center;gap:6px">
${agent.version ? `<span style="font-family:var(--font-mono);background:var(--bg-1);border:1px solid var(--border-2);border-radius:5px;padding:1px 7px;font-size:11px;color:var(--accent)">v${esc(agent.version)}</span>` : '<span style="color:var(--ink-4)">—</span>'}
</div></div>
<div class="meta"><div class="meta-lbl">PROTOCOLES ACTIFS</div><div style="display:flex;gap:5px;margin-top:4px">${protos || '—'}</div></div>
<div class="meta"><div class="meta-lbl">DERNIER CONTACT</div><div class="meta-val">${new Date(agent.last_seen * 1000).toLocaleTimeString('fr-FR')}</div></div>
</div>
@@ -278,6 +315,11 @@ const Popups = (() => {
<input type="range" class="scfg-slider" min="10" max="18" value="${cfg.font_size ?? 13}"
oninput="this.nextElementSibling.textContent=this.value+'px'" id="s-font">
<span class="scfg-val">${cfg.font_size ?? 13}px</span></div>
<div class="scfg-row"><label>Type de jauge</label>
<select class="scfg-select" id="s-gauge-type">
<option value="compact" ${(cfg.gauge_type ?? 'compact') === 'compact' ? 'selected' : ''}>Compact</option>
<option value="standard" ${(cfg.gauge_type ?? 'compact') === 'standard' ? 'selected' : ''}>Standard</option>
</select></div>
</div>
<div style="display:flex;flex-direction:column;gap:8px">
<div class="scfg-sec-title">SEUILS D'ALERTE</div>
@@ -309,7 +351,6 @@ const Popups = (() => {
<div style="display:flex;flex-direction:column;gap:8px">
<div class="scfg-sec-title">INSTALLATION AGENT</div>
<div class="scfg-row" style="flex-direction:column;align-items:stretch;gap:6px">
<label style="font-size:0.85em;color:var(--fg2)">Commande curl — copiez et lancez en root sur la machine cible</label>
<div style="display:flex;gap:6px;align-items:center">
<input type="text" id="s-install-cmd" readonly
style="flex:1;font-family:var(--font-mono);font-size:0.78em;padding:6px 8px;
@@ -317,7 +358,7 @@ const Popups = (() => {
color:var(--fg);cursor:text;min-width:0"
value="SERVER_IP=${window.location.hostname} curl -fsSL https://git.maison43gil.com/gilles/nano_metrics/raw/branch/main/deploy/install.sh | sudo bash">
<button class="btn" style="padding:5px 10px;font-size:0.8em;white-space:nowrap"
onclick="navigator.clipboard.writeText(document.getElementById('s-install-cmd').value).then(()=>{this.textContent='✓ Copié';setTimeout(()=>this.textContent='Copier',1500)})">Copier</button>
onclick="Popups._copyInstallCmd(this)">Copier</button>
</div>
</div>
</div>`;
@@ -339,66 +380,110 @@ const Popups = (() => {
warn_disk: parseInt(document.getElementById('s-warn-disk')?.value ?? 75),
retention_days: parseInt(document.getElementById('s-retention')?.value ?? 30),
chart_duration_min: parseInt(document.getElementById('s-chart-dur')?.value ?? 30),
gauge_type: document.getElementById('s-gauge-type')?.value ?? 'compact',
};
const prevGaugeType = App.serverConfig?.gauge_type ?? 'compact';
await API.putServerConfig(cfg);
App.serverConfig = cfg;
document.documentElement.style.setProperty('--tile-min', cfg.tile_min_width + 'px');
document.body.style.fontSize = cfg.font_size + 'px';
if (cfg.gauge_type !== prevGaugeType) Grid.rerenderAll();
hideSrvCfg();
}
// ══ POPUP SMART ══
function showSmart(agentId) {
const m = Grid.getAgent(agentId)?.metrics?.smart;
if (!m) return;
document.getElementById('smart-sub').textContent = agentId;
const passColor = m.passed ? 'var(--ok)' : 'var(--err)';
const passText = m.passed ? 'Disque en bonne santé' : 'Disque en mauvais état';
const passSub = m.passed
? 'Aucun problème détecté. Le disque fonctionne normalement.'
: 'Des problèmes ont été détectés. Envisagez un remplacement.';
function showSmart(agentId, diskIdx = 0) {
const smartList = Grid.getAgent(agentId)?.metrics?.smart;
if (!smartList?.length) return;
const m = smartList[diskIdx] ?? smartList[0];
const state = Grid.smartState(m);
document.getElementById('smart-sub').textContent = m.device ? `${agentId}${m.device}` : agentId;
const stateInfo = {
ok: { color:'var(--ok)', bg:'rgba(77,187,38,.1)', border:'rgba(77,187,38,.3)', icon:'fa-circle-check',
title:'Disque en bonne santé',
desc:'Aucun problème détecté. Votre disque fonctionne normalement.' },
old: { color:'var(--warn)', bg:'rgba(250,189,47,.1)', border:'rgba(250,189,47,.3)', icon:'fa-clock-rotate-left',
title:'Disque ancien ou très utilisé',
desc:'Votre disque fonctionne encore, mais il a accumulé beaucoup d\'heures. Pensez à prévoir un remplacement.' },
prefail: { color:'var(--accent)', bg:'var(--accent-tint)', border:'rgba(254,128,25,.3)', icon:'fa-triangle-exclamation',
title:'Signes de défaillance imminente',
desc:'Ce disque présente des indicateurs préoccupants. Sauvegardez vos données dès maintenant et envisagez un remplacement rapide.' },
hs: { color:'var(--err)', bg:'rgba(251,73,52,.1)', border:'rgba(251,73,52,.3)', icon:'fa-circle-xmark',
title:'Disque défaillant',
desc:'Ce disque a échoué au test SMART. Il peut tomber en panne à tout moment. Sauvegardez immédiatement et remplacez-le.' },
};
const si = stateInfo[state];
const tempColor = m.temperature == null ? null
: m.temperature > 60 ? 'var(--err)' : m.temperature > 50 ? 'var(--warn)' : 'var(--ok)';
const tempLabel = m.temperature == null ? null
: m.temperature > 60 ? 'Critique' : m.temperature > 50 ? 'Élevée' : 'Normale';
const tempBg = tempColor === 'var(--ok)' ? 'rgba(77,187,38,.15)'
: tempColor === 'var(--warn)' ? 'rgba(250,189,47,.15)' : 'rgba(251,73,52,.15)';
const secColor = m.reallocated_sectors == null ? null
: m.reallocated_sectors === 0 ? 'var(--ok)' : m.reallocated_sectors < 10 ? 'var(--warn)' : 'var(--err)';
const secDesc = m.reallocated_sectors === 0
? 'Aucun secteur défectueux — parfait.'
: m.reallocated_sectors < 10 ? 'Quelques secteurs remplacés. Surveillez l\'évolution.'
: 'Nombreux secteurs défectueux — risque de panne élevé.';
const hoursColor = m.power_on_hours == null ? null
: m.power_on_hours > 40000 ? 'var(--err)' : m.power_on_hours > 25000 ? 'var(--warn)' : 'var(--ok)';
const wearColor = m.wear_level == null ? null
: m.wear_level < 20 ? 'var(--err)' : m.wear_level < 50 ? 'var(--warn)' : 'var(--ok)';
const wearDesc = m.wear_level == null ? ''
: m.wear_level >= 80 ? 'Très bonne durée de vie restante.'
: m.wear_level >= 50 ? 'Durée de vie acceptable, à surveiller.'
: m.wear_level >= 20 ? 'Durée de vie réduite — pensez au remplacement.'
: 'Durée de vie critique — remplacez ce SSD rapidement.';
document.getElementById('smart-body').innerHTML = `
<div class="smart-verdict" style="${m.passed ? '' : 'background:rgba(251,73,52,.1);border-color:rgba(251,73,52,.3)'}">
<div style="font-size:28px;color:${passColor}"><i class="fa-solid ${m.passed ? 'fa-circle-check' : 'fa-circle-xmark'}"></i></div>
<div><div style="font-size:16px;font-weight:700;color:${passColor}">${passText}</div>
<div style="font-size:12px;color:var(--ink-3);margin-top:3px">${passSub}</div></div>
<div class="smart-verdict" style="background:${si.bg};border-color:${si.border}">
<div style="font-size:28px;color:${si.color}"><i class="fa-solid ${si.icon}"></i></div>
<div>
<div style="font-size:16px;font-weight:700;color:${si.color}">${si.title}</div>
<div style="font-size:12px;color:var(--ink-3);margin-top:3px">${si.desc}</div>
</div>
</div>
<div>
<div class="sec-title">POINTS DE CONTRÔLE</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px">
${m.temperature != null ? `<div style="background:var(--bg-3);border-radius:8px;padding:12px 14px;border:1px solid var(--border-1)">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px">
<span style="color:var(--warn);font-size:14px;width:22px;text-align:center"><i class="fa-solid fa-temperature-half"></i></span>
<span style="color:${tempColor};font-size:14px;width:22px;text-align:center"><i class="fa-solid fa-temperature-half"></i></span>
<span style="font-weight:600;font-size:12px;flex:1">Température</span>
<span style="font-size:10px;font-family:var(--font-terminal);font-weight:700;padding:1px 7px;border-radius:999px;background:rgba(77,187,38,.15);color:var(--ok)">Normale</span>
<span style="font-size:10px;font-family:var(--font-terminal);font-weight:700;padding:1px 7px;border-radius:999px;background:${tempBg};color:${tempColor}">${tempLabel}</span>
</div>
<div class="si-val">${m.temperature}<span class="u">°C</span></div>
<div class="si-desc">Idéal : 2050°C. Au-delà de 60°C le disque risque de s'abîmer.</div>
<div class="si-desc">Normale entre 2050°C. Au-delà de 60°C le disque risque de s'abîmer.</div>
</div>` : ''}
${m.reallocated_sectors != null ? `<div style="background:var(--bg-3);border-radius:8px;padding:12px 14px;border:1px solid var(--border-1)">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px">
<span style="color:${m.reallocated_sectors > 0 ? 'var(--err)' : 'var(--ok)'};font-size:14px;width:22px;text-align:center"><i class="fa-solid fa-circle-check"></i></span>
<span style="color:${secColor};font-size:14px;width:22px;text-align:center"><i class="fa-solid ${m.reallocated_sectors === 0 ? 'fa-circle-check' : 'fa-circle-exclamation'}"></i></span>
<span style="font-weight:600;font-size:12px;flex:1">Secteurs défectueux</span>
</div>
<div class="si-val">${m.reallocated_sectors}<span class="u"> sect.</span></div>
<div class="si-desc">S'ils apparaissent en grand nombre, une panne est imminente.</div>
<div class="si-desc">${secDesc}</div>
</div>` : ''}
${m.power_on_hours != null ? `<div style="background:var(--bg-3);border-radius:8px;padding:12px 14px;border:1px solid var(--border-1)">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px">
<span style="color:var(--blue);font-size:14px;width:22px;text-align:center"><i class="fa-solid fa-clock-rotate-left"></i></span>
<span style="font-weight:600;font-size:12px;flex:1">Heures de fonctionnement</span>
<span style="color:${hoursColor};font-size:14px;width:22px;text-align:center"><i class="fa-solid fa-clock-rotate-left"></i></span>
<span style="font-weight:600;font-size:12px;flex:1">Durée de fonctionnement</span>
</div>
<div class="si-val">${m.power_on_hours.toLocaleString('fr-FR')}<span class="u">h</span></div>
<div class="si-desc">≈${Math.floor(m.power_on_hours/24)} jours. Un disque dure en moyenne 3 à 5 ans.</div>
<div class="si-val">${m.power_on_hours.toLocaleString('fr-FR')}<span class="u"> h</span></div>
<div class="si-desc">≈${Math.floor(m.power_on_hours / 24)} jours d'utilisation. Un disque dur dure en moyenne 3 à 5 ans (25 00040 000 h).</div>
</div>` : ''}
${m.wear_level != null ? `<div style="background:var(--bg-3);border-radius:8px;padding:12px 14px;border:1px solid var(--border-1)">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px">
<span style="color:var(--ok);font-size:14px;width:22px;text-align:center"><i class="fa-solid fa-battery-full"></i></span>
<span style="font-weight:600;font-size:12px;flex:1">Durée de vie SSD</span>
<span style="color:${wearColor};font-size:14px;width:22px;text-align:center"><i class="fa-solid fa-battery-full"></i></span>
<span style="font-weight:600;font-size:12px;flex:1">Durée de vie SSD restante</span>
</div>
<div class="si-val">${m.wear_level}<span class="u">%</span></div>
<div class="si-desc">100% = neuf · 0% = fin de vie recommandée.</div>
<div class="si-desc">${wearDesc}</div>
</div>` : ''}
</div>
</div>`;
@@ -428,10 +513,31 @@ const Popups = (() => {
}
}
function _copyInstallCmd(btn) {
const text = document.getElementById('s-install-cmd').value;
const done = () => { btn.textContent = '✓ Copié'; setTimeout(() => btn.textContent = 'Copier', 1500); };
if (window.isSecureContext && navigator.clipboard?.writeText) {
navigator.clipboard.writeText(text).then(done).catch(() => _copyFallback(text, done));
} else {
_copyFallback(text, done);
}
}
function _copyFallback(text, cb) {
const ta = document.createElement('textarea');
ta.value = text;
ta.style.cssText = 'position:fixed;opacity:0;top:0;left:0';
document.body.appendChild(ta);
ta.focus();
ta.select();
try { document.execCommand('copy'); cb(); } catch (_) {}
document.body.removeChild(ta);
}
return {
showDetail, hideDetail,
showAgentCfg, sendAgentConfig, toggleCbox,
showSrvCfg, hideSrvCfg, saveSrvCfg, confirmDeleteAgent, doDeleteAgent,
showSmart,
showSmart, _copyInstallCmd,
};
})();
+39 -17
View File
@@ -29,7 +29,23 @@ echo " Nanometrics Agent — Installation"
echo "======================================"
echo ""
# ── 1. Détection de l'architecture ────────────────────────────────────────────
# ── 1. Dépendances système ─────────────────────────────────────────────────────
PKGS_NEEDED=()
for pkg in curl python3 smartmontools ethtool; do
dpkg -l "$pkg" 2>/dev/null | grep -q '^ii' || PKGS_NEEDED+=("$pkg")
done
if [ ${#PKGS_NEEDED[@]} -gt 0 ]; then
echo "→ Installation des paquets manquants : ${PKGS_NEEDED[*]}"
apt-get update -qq
apt-get install -y -qq "${PKGS_NEEDED[@]}"
ok "Paquets installés : ${PKGS_NEEDED[*]}"
else
ok "Dépendances système déjà présentes"
fi
echo ""
# ── 3. Détection de l'architecture ────────────────────────────────────────────
ARCH="$(uname -m)"
case "$ARCH" in
x86_64) LABEL="linux-amd64" ;;
@@ -42,7 +58,7 @@ case "$ARCH" in
esac
ok "Architecture détectée : $ARCH$LABEL"
# ── 2. Récupérer l'URL du binaire depuis la dernière release ──────────────────
# ── 4. Récupérer l'URL du binaire depuis la dernière release ──────────────────
echo "→ Récupération de la dernière release..."
ASSETS_JSON=$(curl -sf "$REPO_API/releases?limit=1&page=1")
@@ -69,7 +85,7 @@ print(releases[0]['tag_name'])
ok "Release : $TAG — URL : $ASSET_URL"
# ── 3. Télécharger le binaire ─────────────────────────────────────────────────
# ── 5. Télécharger le binaire ─────────────────────────────────────────────────
TMP_BIN="$(mktemp)"
trap 'rm -f "$TMP_BIN"' EXIT
@@ -78,7 +94,7 @@ curl -fsSL -o "$TMP_BIN" "$ASSET_URL"
chmod 755 "$TMP_BIN"
ok "Binaire téléchargé ($(du -sh "$TMP_BIN" | cut -f1))"
# ── 4. Paramètres de configuration ────────────────────────────────────────────
# ── 6. Paramètres de configuration ────────────────────────────────────────────
echo ""
echo "--- Configuration du serveur ---"
@@ -89,26 +105,29 @@ MQTT_ENABLED="${MQTT_ENABLED:-false}"
ok "Serveur : $SERVER_IP:$SERVER_PORT | MQTT broker : $MQTT_HOST"
# ── 5. Installer le binaire ────────────────────────────────────────────────────
# ── 7. Installer le binaire ────────────────────────────────────────────────────
echo ""
echo "[1/5] Installation du binaire dans /usr/local/bin/"
echo "[1/5] Installation du binaire..."
# Arrêter le service si en cours (le binaire ne peut pas être écrasé à chaud)
if systemctl is-active --quiet nanometrics-agent 2>/dev/null; then
warn "Service en cours — arrêt temporaire..."
systemctl stop nanometrics-agent
fi
cp "$TMP_BIN" "$INSTALL_BIN"
chmod 755 "$INSTALL_BIN"
ok "Binaire installé"
# ── 6. Créer le répertoire de configuration ───────────────────────────────────
# ── 8. Créer le répertoire de configuration ───────────────────────────────────
echo "[2/5] Création de $CONFIG_DIR"
mkdir -p "$CONFIG_DIR"
chmod 755 "$CONFIG_DIR"
ok "Répertoire créé"
# ── 7. Écrire config.toml ─────────────────────────────────────────────────────
# ── 9. Écrire config.toml ─────────────────────────────────────────────────────
echo "[3/5] Écriture de $CONFIG_FILE"
# Ne pas écraser une config existante (upgrade)
if [ -f "$CONFIG_FILE" ]; then
warn "config.toml déjà présent — conservé tel quel"
else
cat > "$CONFIG_FILE" << TOML
[server]
ip = "$SERVER_IP"
@@ -154,11 +173,14 @@ mqtt = false
udp = true
mqtt = false
TOML
chmod 644 "$CONFIG_FILE"
ok "config.toml créé"
fi
chmod 644 "$CONFIG_FILE"
ok "config.toml écrit"
# ── 8. Installer le fichier service ──────────────────────────────────────────
# S'assurer que le répertoire est accessible
chmod 644 "$CONFIG_FILE" 2>/dev/null || true
chmod 755 "$CONFIG_DIR"
# ── 10. Installer le fichier service ─────────────────────────────────────────
echo "[4/5] Installation du service systemd"
curl -fsSL -o "$SERVICE_FILE" "$SERVICE_URL"
chmod 644 "$SERVICE_FILE"
@@ -166,7 +188,7 @@ systemctl daemon-reload
systemctl enable nanometrics-agent
ok "Service installé et activé"
# ── 9. Démarrer le service ───────────────────────────────────────────────────
# ── 11. Démarrer le service ───────────────────────────────────────────────────
echo "[5/5] Démarrage du service"
systemctl restart nanometrics-agent
sleep 2
+2 -1
View File
@@ -10,8 +10,9 @@ Restart=on-failure
RestartSec=5
DynamicUser=yes
SupplementaryGroups=disk
ConfigurationDirectory=nanometrics
ConfigurationDirectoryMode=0750
ConfigurationDirectoryMode=0755
ProtectSystem=strict
ProtectHome=read-only
+3 -1
View File
@@ -25,7 +25,9 @@ DESCRIPTION="${2:-Release $TAG}"
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
CARGO_TOML="$ROOT/agent/Cargo.toml"
# ── 1. Compiler pour toutes les cibles supportées ──────────────────────────
mkdir -p "$ROOT/dist"
# ── 1. Compiler l'agent pour toutes les cibles supportées ────────────────
echo "=== Compilation de l'agent ==="
TARGETS=("x86_64-unknown-linux-musl" "aarch64-unknown-linux-musl")
LABELS=("linux-amd64" "linux-arm64")
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+6 -6
View File
@@ -1,14 +1,14 @@
FROM golang:1.22-alpine AS builder
ARG GO_IMAGE=public.ecr.aws/docker/library/golang:1.25-alpine
FROM ${GO_IMAGE} AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o nanometrics-server .
FROM alpine:3.19
RUN apk add --no-cache ca-certificates
WORKDIR /app
COPY --from=builder /app/nanometrics-server .
FROM scratch
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=builder /app/nanometrics-server /nanometrics-server
VOLUME /data
EXPOSE 8080 9999/udp
CMD ["./nanometrics-server"]
CMD ["/nanometrics-server"]
+2 -1
View File
@@ -1,5 +1,6 @@
# Dockerfile de dev : utilise le binaire pré-compilé localement (pas de pull Docker Hub)
FROM nginx:alpine
ARG NGINX_IMAGE=public.ecr.aws/docker/library/nginx:alpine
FROM ${NGINX_IMAGE}
COPY nanometrics-server /app/nanometrics-server
WORKDIR /app
VOLUME /data
+80 -22
View File
@@ -20,7 +20,7 @@ const schema = `
CREATE TABLE IF NOT EXISTS agents (
id TEXT PRIMARY KEY, hostname TEXT NOT NULL,
ip TEXT NOT NULL DEFAULT '', status TEXT NOT NULL DEFAULT 'offline',
last_seen INTEGER NOT NULL DEFAULT 0
last_seen INTEGER NOT NULL DEFAULT 0, version TEXT NOT NULL DEFAULT ''
);
CREATE TABLE IF NOT EXISTS metrics (
id INTEGER PRIMARY KEY AUTOINCREMENT, agent_id TEXT NOT NULL, ts INTEGER NOT NULL,
@@ -57,8 +57,18 @@ func Open(path string) (*DB, error) {
}
func (d *DB) migrate() error {
_, err := d.conn.Exec(schema)
return err
if _, err := d.conn.Exec(schema); err != nil {
return err
}
// Migrations additives — ignorées si la colonne existe déjà
_, _ = d.conn.Exec(`ALTER TABLE agents ADD COLUMN version TEXT NOT NULL DEFAULT ''`)
_, _ = d.conn.Exec(`ALTER TABLE metrics ADD COLUMN smart_passed INTEGER`)
_, _ = d.conn.Exec(`ALTER TABLE metrics ADD COLUMN smart_temp 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_wear INTEGER`)
_, _ = d.conn.Exec(`ALTER TABLE metrics ADD COLUMN smart_json TEXT`)
return nil
}
func (d *DB) Close() { _ = d.conn.Close() }
@@ -66,45 +76,40 @@ func (d *DB) Close() { _ = d.conn.Close() }
func (d *DB) UpsertAgent(m *models.AgentMetrics) error {
ts := time.Now().Unix()
_, err := d.conn.Exec(`
INSERT INTO agents (id, hostname, ip, status, last_seen)
VALUES (?, ?, ?, ?, ?)
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`,
m.Hostname, m.Hostname, m.IP, m.Status, ts)
ip=excluded.ip, status=excluded.status, last_seen=excluded.last_seen,
version=CASE WHEN excluded.version != '' THEN excluded.version ELSE version END`,
m.Hostname, m.Hostname, m.IP, m.Status, ts, m.Version)
return err
}
func (d *DB) InsertMetrics(m *models.AgentMetrics) error {
ts := time.Now().Unix()
var smartPassed, smartTemp, smartRealloc, smartHours, smartWear interface{}
if m.Smart != nil {
b := 0
if m.Smart.Passed {
b = 1
var smartJSON interface{}
if len(m.Smart) > 0 {
if b, err := json.Marshal(m.Smart); err == nil {
smartJSON = string(b)
}
smartPassed = b
smartTemp = m.Smart.Temperature
smartRealloc = m.Smart.ReallocatedSectors
smartHours = m.Smart.PowerOnHours
smartWear = m.Smart.WearLevel
}
_, err := d.conn.Exec(`
INSERT INTO metrics (agent_id, ts,
cpu_percent, memory_used, memory_free, memory_total,
hdd_used, hdd_free, hdd_total,
uptime, network_rx, network_tx, temperature,
smart_passed, smart_temp, smart_realloc, smart_hours, smart_wear)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
smart_json)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
m.Hostname, ts,
m.CPUPercent, m.MemoryUsed, m.MemoryFree, m.MemoryTotal,
m.HDDUsed, m.HDDFree, m.HDDTotal,
m.Uptime, m.NetworkRX, m.NetworkTX, m.Temperature,
smartPassed, smartTemp, smartRealloc, smartHours, smartWear)
smartJSON)
return err
}
func (d *DB) GetAgents() ([]models.Agent, error) {
rows, err := d.conn.Query(`SELECT id, hostname, ip, status, last_seen FROM agents`)
rows, err := d.conn.Query(`SELECT id, hostname, ip, status, last_seen, version FROM agents`)
if err != nil {
return nil, err
}
@@ -112,7 +117,7 @@ func (d *DB) GetAgents() ([]models.Agent, error) {
var agents []models.Agent
for rows.Next() {
var a models.Agent
if err := rows.Scan(&a.ID, &a.Hostname, &a.IP, &a.Status, &a.LastSeen); err != nil {
if err := rows.Scan(&a.ID, &a.Hostname, &a.IP, &a.Status, &a.LastSeen, &a.Version); err != nil {
return nil, err
}
agents = append(agents, a)
@@ -123,6 +128,59 @@ func (d *DB) GetAgents() ([]models.Agent, error) {
return agents, nil
}
func (d *DB) GetLastMetrics(agentID string) (*models.AgentMetrics, error) {
var cpu, temperature *float64
var memUsed, memFree, memTotal, hddUsed, hddFree, hddTotal *int64
var uptime, netRX, netTX *int64
var smartJSON *string
err := d.conn.QueryRow(`
SELECT
(SELECT cpu_percent FROM metrics WHERE agent_id=? AND cpu_percent IS NOT NULL ORDER BY ts DESC LIMIT 1),
(SELECT memory_used FROM metrics WHERE agent_id=? AND memory_used IS NOT NULL ORDER BY ts DESC LIMIT 1),
(SELECT memory_free FROM metrics WHERE agent_id=? AND memory_free IS NOT NULL ORDER BY ts DESC LIMIT 1),
(SELECT memory_total FROM metrics WHERE agent_id=? AND memory_total IS NOT NULL ORDER BY ts DESC LIMIT 1),
(SELECT hdd_used FROM metrics WHERE agent_id=? AND hdd_used IS NOT NULL ORDER BY ts DESC LIMIT 1),
(SELECT hdd_free FROM metrics WHERE agent_id=? AND hdd_free IS NOT NULL ORDER BY ts DESC LIMIT 1),
(SELECT hdd_total FROM metrics WHERE agent_id=? AND hdd_total IS NOT NULL ORDER BY ts DESC LIMIT 1),
(SELECT uptime FROM metrics WHERE agent_id=? AND uptime 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 temperature FROM metrics WHERE agent_id=? AND temperature 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)`,
agentID, agentID, agentID, agentID,
agentID, agentID, agentID,
agentID, agentID, agentID, agentID,
agentID).
Scan(&cpu, &memUsed, &memFree, &memTotal,
&hddUsed, &hddFree, &hddTotal,
&uptime, &netRX, &netTX, &temperature,
&smartJSON)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
m := &models.AgentMetrics{
CPUPercent: cpu,
MemoryUsed: memUsed,
MemoryFree: memFree,
MemoryTotal: memTotal,
HDDUsed: hddUsed,
HDDFree: hddFree,
HDDTotal: hddTotal,
Uptime: uptime,
NetworkRX: netRX,
NetworkTX: netTX,
Temperature: temperature,
}
if smartJSON != nil {
_ = json.Unmarshal([]byte(*smartJSON), &m.Smart)
}
return m, nil
}
func (d *DB) GetMetricsHistory(agentID string, from, to int64) ([]map[string]interface{}, error) {
rows, err := d.conn.Query(`
SELECT ts, cpu_percent, memory_used, memory_total, hdd_used, hdd_total
+1 -1
View File
@@ -15,7 +15,7 @@ services:
- "9999:9999/udp"
dashboard:
image: nginx:alpine
image: ${NGINX_IMAGE:-public.ecr.aws/docker/library/nginx:alpine}
restart: unless-stopped
volumes:
- ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf:ro
+7 -3
View File
@@ -1,7 +1,10 @@
version: '3.8'
services:
server:
build: .
build:
context: .
pull: false
args:
GO_IMAGE: ${GO_IMAGE:-public.ecr.aws/docker/library/golang:1.25-alpine}
restart: unless-stopped
environment:
UDP_ADDR: "0.0.0.0:9999"
@@ -15,7 +18,8 @@ services:
- "9999:9999/udp"
dashboard:
image: nginx:alpine
image: ${NGINX_IMAGE:-public.ecr.aws/docker/library/nginx:alpine}
pull_policy: if_not_present
restart: unless-stopped
volumes:
- ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf:ro
+6 -1
View File
@@ -6,6 +6,7 @@ import (
"strings"
"github.com/user/nanometrics/server/db"
"github.com/user/nanometrics/server/models"
)
func AgentsHandler(database *db.DB) http.HandlerFunc {
@@ -15,12 +16,15 @@ func AgentsHandler(database *db.DB) http.HandlerFunc {
http.Error(w, err.Error(), 500)
return
}
for i := range agents {
agents[i].LastMetrics, _ = database.GetLastMetrics(agents[i].ID)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(agents)
}
}
func DeleteAgentHandler(database *db.DB) http.HandlerFunc {
func DeleteAgentHandler(database *db.DB, broadcast func(interface{})) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/")
if len(parts) < 3 {
@@ -32,6 +36,7 @@ func DeleteAgentHandler(database *db.DB) http.HandlerFunc {
http.Error(w, err.Error(), 500)
return
}
broadcast(models.WSMessage{Type: "agent_removed", AgentID: agentID})
w.WriteHeader(http.StatusNoContent)
}
}
+2
View File
@@ -3,6 +3,7 @@ package handlers
import (
"bytes"
"image"
_ "image/gif"
_ "image/jpeg"
"image/png"
"io"
@@ -11,6 +12,7 @@ import (
"github.com/disintegration/imaging"
"github.com/user/nanometrics/server/db"
_ "golang.org/x/image/webp"
)
const maxIconSize = 128
+4 -1
View File
@@ -20,6 +20,8 @@ import (
ws "github.com/user/nanometrics/server/websocket"
)
const serverVersion = "0.1.0"
func main() {
cfg := config.Load()
@@ -92,6 +94,7 @@ func main() {
if err != nil {
continue
}
stats.Version = serverVersion
hub.Broadcast(models.WSMessage{Type: "server_stats", Data: stats})
}
}()
@@ -111,7 +114,7 @@ func main() {
case endsWith(r.URL.Path, "/icon") && r.Method == http.MethodGet:
handlers.IconGetHandler(database)(w, r)
case r.Method == http.MethodDelete:
handlers.DeleteAgentHandler(database)(w, r)
handlers.DeleteAgentHandler(database, hub.Broadcast)(w, r)
default:
http.NotFound(w, r)
}
+18 -12
View File
@@ -4,6 +4,7 @@ type AgentMetrics struct {
Hostname string `json:"hostname"`
IP string `json:"ip"`
Status string `json:"status"`
Version string `json:"version"`
CPUPercent *float64 `json:"cpu_percent"`
MemoryUsed *int64 `json:"memory_used"`
MemoryFree *int64 `json:"memory_free"`
@@ -15,10 +16,11 @@ type AgentMetrics struct {
NetworkRX *int64 `json:"network_rx"`
NetworkTX *int64 `json:"network_tx"`
Temperature *float64 `json:"temperature"`
Smart *SmartMetrics `json:"smart"`
Smart []SmartMetrics `json:"smart"`
}
type SmartMetrics struct {
Device string `json:"device"`
Passed bool `json:"passed"`
Temperature *int64 `json:"temperature"`
ReallocatedSectors *int64 `json:"reallocated_sectors"`
@@ -32,6 +34,7 @@ type Agent struct {
IP string `json:"ip"`
Status string `json:"status"`
LastSeen int64 `json:"last_seen"`
Version string `json:"version,omitempty"`
LastMetrics *AgentMetrics `json:"last_metrics,omitempty"`
}
@@ -75,17 +78,18 @@ type MetricProto struct {
}
type ServerConfig struct {
TileMinWidth int `json:"tile_min_width"`
FontSize int `json:"font_size"`
WarnCPU int `json:"warn_cpu"`
ErrCPU int `json:"err_cpu"`
WarnDisk int `json:"warn_disk"`
RetentionDays int `json:"retention_days"`
ChartDurationMin int `json:"chart_duration_min"`
HideOffline bool `json:"hide_offline"`
Notifications bool `json:"notifications"`
PopupDetailW int `json:"popup_detail_w"`
PopupDetailH int `json:"popup_detail_h"`
TileMinWidth int `json:"tile_min_width"`
FontSize int `json:"font_size"`
WarnCPU int `json:"warn_cpu"`
ErrCPU int `json:"err_cpu"`
WarnDisk int `json:"warn_disk"`
RetentionDays int `json:"retention_days"`
ChartDurationMin int `json:"chart_duration_min"`
HideOffline bool `json:"hide_offline"`
Notifications bool `json:"notifications"`
PopupDetailW int `json:"popup_detail_w"`
PopupDetailH int `json:"popup_detail_h"`
GaugeType string `json:"gauge_type"`
}
func DefaultAgentConfig() *AgentConfig {
@@ -109,6 +113,7 @@ func DefaultServerConfig() ServerConfig {
RetentionDays: 30, ChartDurationMin: 30,
HideOffline: false, Notifications: true,
PopupDetailW: 560, PopupDetailH: 600,
GaugeType: "compact",
}
}
@@ -122,4 +127,5 @@ type ServerStats struct {
CPUPercent float64 `json:"cpu_percent"`
MemUsed int64 `json:"mem_used"`
MemTotal int64 `json:"mem_total"`
Version string `json:"version"`
}
BIN
View File
Binary file not shown.
+1
View File
@@ -2,6 +2,7 @@ server {
listen 80;
root /usr/share/nginx/html;
index index.html;
client_max_body_size 10m;
location /api/ {
proxy_pass http://server:8080;