12 Commits

Author SHA1 Message Date
Gilles Soulier e65770407c chore(agent): bump version 0.1.2 → 0.1.3 2026-05-22 22:35:05 +02:00
Gilles Soulier 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 Soulier 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 Soulier 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 Soulier 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 Soulier 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 Soulier 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 Soulier 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 Soulier 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 Soulier 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 Soulier 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
22 changed files with 182 additions and 22 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "nanometrics-agent"
version = "0.1.2"
version = "0.1.3"
edition = "2021"
[lib]
+33 -1
View File
@@ -2,6 +2,13 @@ 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};
static RUNNING: AtomicBool = AtomicBool::new(true);
extern "C" fn handle_signal(_: libc::c_int) {
RUNNING.store(false, Ordering::Relaxed);
}
fn get_local_ip() -> String {
use std::net::UdpSocket;
@@ -49,12 +56,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() {
@@ -134,4 +146,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();
}
}
+1
View File
@@ -5,6 +5,7 @@ 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>,
+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);
}
+1
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\""));
Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+3 -3
View File
@@ -73,12 +73,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>
+3 -3
View File
@@ -58,7 +58,7 @@ const Grid = (() => {
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)}')">
@@ -87,7 +87,7 @@ const Grid = (() => {
<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>
<span class="g-val">${offline ? '—' : (metrics?.hdd_used && metrics?.hdd_total ? fmt(metrics.hdd_used) + '/' + fmt(metrics.hdd_total) : '—')}</span>
</div>
</div>
<div class="tile-foot">
@@ -124,7 +124,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;
}
+21 -2
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
+30 -3
View File
@@ -112,10 +112,33 @@ ok "Répertoire créé"
# ── 7. Écrire config.toml ─────────────────────────────────────────────────────
echo "[3/5] Écriture de $CONFIG_FILE"
# Ne pas écraser une config existante (upgrade)
OVERWRITE_CONFIG="${OVERWRITE_CONFIG:-}"
WRITE_CONFIG=true
if [ -f "$CONFIG_FILE" ]; then
warn "config.toml déjà présent — conservé tel quel"
else
if [ "${OVERWRITE_CONFIG}" = "true" ]; then
warn "OVERWRITE_CONFIG=true — config.toml sera écrasé"
WRITE_CONFIG=true
elif [ -t 0 ]; then
# Mode interactif (bash local, pas curl | bash)
echo ""
warn "Un config.toml existe déjà :"
echo " $CONFIG_FILE"
printf " Écraser la configuration existante ? [o/N] : "
read -r _ANS
if [[ "$_ANS" =~ ^[Oo]$ ]]; then
WRITE_CONFIG=true
else
ok "config.toml conservé"
WRITE_CONFIG=false
fi
else
warn "config.toml déjà présent — conservé (relancez avec OVERWRITE_CONFIG=true pour écraser)"
WRITE_CONFIG=false
fi
fi
if [ "$WRITE_CONFIG" = "true" ]; then
cat > "$CONFIG_FILE" << TOML
[server]
ip = "$SERVER_IP"
@@ -165,6 +188,10 @@ TOML
ok "config.toml créé"
fi
# S'assurer que le fichier est toujours lisible (cas d'un config existant en 640)
chmod 644 "$CONFIG_FILE" 2>/dev/null || true
chmod 755 "$CONFIG_DIR"
# ── 8. Installer le fichier service ──────────────────────────────────────────
echo "[4/5] Installation du service systemd"
curl -fsSL -o "$SERVICE_FILE" "$SERVICE_URL"
+1 -1
View File
@@ -11,7 +11,7 @@ RestartSec=5
DynamicUser=yes
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.
+4 -5
View File
@@ -5,10 +5,9 @@ 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"]
+66
View File
@@ -128,6 +128,72 @@ 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 smartPassed, smartTemp, smartRealloc, smartHours, smartWear *int64
// Chaque sous-requête prend la dernière valeur NON NULL de sa colonne.
// Nécessaire car les paquets rapides (2s) ne contiennent pas les métriques
// lentes (disque, smart) qui sont envoyées toutes les 60s.
err := d.conn.QueryRow(`
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_passed FROM metrics WHERE agent_id=? AND smart_passed IS NOT NULL ORDER BY ts DESC LIMIT 1),
(SELECT smart_temp FROM metrics WHERE agent_id=? AND smart_passed IS NOT NULL ORDER BY ts DESC LIMIT 1),
(SELECT smart_realloc FROM metrics WHERE agent_id=? AND smart_passed IS NOT NULL ORDER BY ts DESC LIMIT 1),
(SELECT smart_hours FROM metrics WHERE agent_id=? AND smart_passed IS NOT NULL ORDER BY ts DESC LIMIT 1),
(SELECT smart_wear FROM metrics WHERE agent_id=? AND smart_passed IS NOT NULL ORDER BY ts DESC LIMIT 1)`,
agentID, agentID, agentID, agentID,
agentID, agentID, agentID,
agentID, agentID, agentID, agentID,
agentID, agentID, agentID, agentID, agentID).
Scan(&cpu, &memUsed, &memFree, &memTotal,
&hddUsed, &hddFree, &hddTotal,
&uptime, &netRX, &netTX, &temperature,
&smartPassed, &smartTemp, &smartRealloc, &smartHours, &smartWear)
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 smartPassed != nil {
m.Smart = &models.SmartMetrics{
Passed: *smartPassed == 1,
Temperature: smartTemp,
ReallocatedSectors: smartRealloc,
PowerOnHours: smartHours,
WearLevel: smartWear,
}
}
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
+4 -2
View File
@@ -1,7 +1,8 @@
version: '3.8'
services:
server:
build: .
build:
context: .
pull: false
restart: unless-stopped
environment:
UDP_ADDR: "0.0.0.0:9999"
@@ -16,6 +17,7 @@ services:
dashboard:
image: nginx:alpine
pull_policy: if_not_present
restart: unless-stopped
volumes:
- ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf:ro
+3
View File
@@ -15,6 +15,9 @@ 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)
}
+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
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;